fix(loop-engine): 「自动收步」留痕去掉 internal —— machinery() 挡下的第五处同病

R10:评审挖出第五个同病的 —— turn-runner.ts 那条「自动收步:…卡已交回」用的是
SKELETON 里就有的 kind: 'engine-transition',但 detail.internal: true 让它照样
被 machinery() 的第一刀滤掉,永远进不了工作日志。保留 item / auto 两个键,只删 internal。

新增回归测试:跑真实的 turn-runner.ts,捕获这一笔留痕喂给 structuredLog 全程,
断言可见且归系统。

顺带修实一条名不副实的既有用例:task-memory.test.ts:453 那条看着像在覆盖
「自动收步」这件事,但 entry() 工厂不设默认 detail,原入参是 {},根本没走到
machinery() 的 internal 过滤 —— 补上生产环境真实的 detail 形状(item / auto 两键)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 07:02:38 +08:00
co-authored by Claude Opus 5
parent 154b222860
commit fc48920c32
3 changed files with 56 additions and 5 deletions
@@ -6,6 +6,8 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts'; import type { TaskId } from '@contracts';
import { deriveSide, structuredLog } from '@modules/memory';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } 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 { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts'; import type { PendingAcceptance } from '../port.ts';
@@ -14,6 +16,30 @@ import type { CaseRuntime } from '../internal/domain/lifecycle.ts';
const TASK = 't-1' as TaskId; const TASK = 't-1' as TaskId;
/** 合成的信封字段 —— 与 `task-memory.test.ts` 的 `entry()` 工厂同一套写法 */
const STAMP: VersionStamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
const GOVERNANCE: GovernanceMark = { masked: false, learnable: true };
/**
* **把捕获到的一笔真实留痕包成完整 `MemoryEntry`,喂给 `structuredLog` 验收**(2026-08-29,R10)。
* 与 `auto-runner.test.ts` 的同名助手同一套写法 —— 见那边的注释。
*/
function asLogEntry(captured: { kind: string; title: string; detail: Record<string, unknown> | undefined }): MemoryEntry {
const kind = captured.kind as EntryKind;
return {
id: 'e-1',
taskId: TASK,
seq: 1,
at: '2026-08-29T00:00:00.000Z',
kind,
side: deriveSide(kind),
title: captured.title,
stamp: STAMP,
governance: GOVERNANCE,
...(captured.detail === undefined ? {} : { detail: captured.detail }),
};
}
/** 空案例模型:必做项全无 + autoComplete → 一进 runTurn 就「判齐」 */ /** 空案例模型:必做项全无 + autoComplete → 一进 runTurn 就「判齐」 */
const READY_MODEL: CaseModel = { name: '空案', autoComplete: true, items: [], sentries: [] } as never; const READY_MODEL: CaseModel = { name: '空案', autoComplete: true, items: [], sentries: [] } as never;
const READY_RUNTIME: CaseRuntime = { items: [] } as never; const READY_RUNTIME: CaseRuntime = { items: [] } as never;
@@ -38,7 +64,7 @@ type Harness = {
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */ /** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
readonly writesAtPush: number[]; readonly writesAtPush: number[];
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */ /** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
readonly appended: Array<{ kind: string; title: string }>; readonly appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }>;
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */ /** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
readonly toolCalls: string[]; readonly toolCalls: string[];
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */ /** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
@@ -174,8 +200,8 @@ function harness(
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }], declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
} as never, } as never,
memory: { memory: {
append: async (entry: { kind: string; title: string }) => { append: async (entry: { kind: string; title: string; detail?: Record<string, unknown> }) => {
appended.push({ kind: entry.kind, title: entry.title }); appended.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
}, },
loadActivityMemory: async () => [], loadActivityMemory: async () => [],
window: async () => over.trail ?? [], window: async () => over.trail ?? [],
@@ -2311,6 +2337,17 @@ describe('auto 步:配方在拍首跑', () => {
const out = await h.runner.runTurn(TASK); const out = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0); expect(h.issued).toHaveLength(0);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
/*
* **配方留痕真的到得了工作日志**(2026-08-29,R10,spec §5.9 验收)。
* 上一轮(Task 12)只测过 actorOf 这个纯函数;这里把「自动收步」这一笔捕获到的
* 真实 {kind, title, detail} 喂给 structuredLog 全程 —— 从前它带着 `internal: true`,
* 会被 `machinery()` 挡在骨架之外,`actorOf` 判得再对也没用。
*/
const settled = h.appended.find((e) => e.title.startsWith('自动收步:'));
expect(settled).toBeDefined();
const lines = structuredLog([asLogEntry(settled!)]);
expect(lines).toHaveLength(1);
expect(lines[0]?.who).toBe('system');
}); });
/* /*
@@ -2037,7 +2037,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
kind: 'engine-transition', kind: 'engine-transition',
/* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */ /* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */
title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`, title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`,
detail: { internal: true, item: current.item, auto: current.item }, /*
* **不再标 internal**(2026-08-29,R10)—— 从前的 `internal: true` 会被
* `task-log.ts` 的 `machinery()` 挡下,这一笔永远进不了工作日志。
* `engine-transition` 本就在骨架里,标题也不匹配「完成/开始办」的正则,
* 只要不被 `machinery()` 滤掉,它就会正确地归系统 —— 不必藏成内部痕。
*/
detail: { item: current.item, auto: current.item },
}); });
await deps.state.save(taskId, model, parsed, settled); await deps.state.save(taskId, model, parsed, settled);
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged, /* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged,
@@ -450,7 +450,15 @@ describe('日志分得清谁干的', () => {
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => { it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
const lines = structuredLog([ const lines = structuredLog([
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }), entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }), /*
* **补上生产环境真实的 detail 形状**(2026-08-29,R10,评审 Important)——
* `entry()` 工厂不设默认 `detail`,从前这一条造出来的入参是 `{}`,
* 根本没走到 `machinery()` 那道 `internal` 过滤,是因为无关的原因才绿的。
* `turn-runner.ts` 真实留下的这一笔带 `item` / `auto` 两个键(见 R10 之后的
* `turn-runner.ts:2039` 一带),这里照实给上,让这条测试真的守住「不带 internal
* 也能显示」这件事,而不是凑巧躲过了过滤。
*/
entry('engine-transition', { seq: 2, title: '自动收步:查历史价', detail: { item: '查历史价', auto: '查历史价' } }),
]); ]);
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']); expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
}); });