From 031c275324975c472e97172b5b0df47af66bb8a7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 25 Jul 2026 22:11:13 +0800 Subject: [PATCH] fix: speed up rectification start --- docs/BUG_HISTORY.md | 15 +++ frontend/src/app/page.tsx | 42 ++++--- .../orchestrator.ts | 116 +++++++++++++----- .../tests/consultation-entrypoint.test.ts | 26 ++++ ...ational-rectification-orchestrator.test.ts | 67 ++++++---- 5 files changed, 192 insertions(+), 74 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2024b2b5..f3818d45 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1353,3 +1353,18 @@ - 防复发:数据库 JSON 合同的日期格式只维护一组精度分支;新增生产迁移时必须执行真实 PostgreSQL 函数调用,而不是只做 SQL 文本断言。 - 相关记录:BUG-067、BUG-070 - 修复版本:本次修复提交(生产已向前迁移) + +## BUG-072 | 生时校正首轮在零事件时执行全范围扫描并阻塞输入 + +- 状态:resolved +- 首次发现:2026-07-25 +- 最近更新:2026-07-25 +- 影响面:首页生时校正入口、`start` 编排、首次会话持久化与咨询问题 handoff +- 用户现象:从首页进入生时校正后长时间停留在加载状态;未知时间或宽时段资料最明显,首问出现后输入框也可能继续等待会话同步。 +- 触发条件:新建生时校正 case,尤其声明时间为全天未知或宽时段;首页没有待转交咨询问题时也会读取 durable handoff。 +- 根因:`start` 在尚无人生事件时仍构建 Technical Packet,按声明范围逐分钟重排,随后再调用叙事模型生成固定性质的首问;前端收到首轮后又串行等待 `persistSession()`,并且直接首页入口无条件读取 durable handoff。 +- 修复:`start` 只按已声明时间范围创建确定性首轮和待验证候选,不做分钟扫描或模型生成;第一条有效事件回答后才进入原技术计算与叙事链。首页先展示首轮并开放输入,再后台同步会话;只有存在本地或显式待转交问题时才读取 durable handoff。 +- 验证:Orchestrator、入口、客户端、路由、持久化与同会话写入队列聚焦测试 144/144 通过;目标 ESLint 与 `git diff --check` 通过。覆盖首轮不扫描/不生成叙事、先展示后按序后台持久化,以及无 handoff 时跳过 durable 读取。 +- 防复发:零证据首轮不得执行分钟级技术计算或模型调用;非关键会话同步不得阻塞已持久化 case 的首轮交互;可选 handoff 读取必须由实际 handoff 状态触发。 +- 相关记录:BUG-055、BUG-065、BUG-067 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 854089d4..098d9da0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2087,10 +2087,11 @@ export default function Home() { : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); const reusingRectificationSession = canReuseSourceRectificationSession || resumableSession !== null; + const localHandoff = rectificationQuestionHandoff.current.peek(); const requestedQuestion = pendingConsultationQuestion ?? (reusingRectificationSession ? null - : rectificationQuestionHandoff.current.peek()?.question) + : localHandoff?.question) ?? null; rectificationOpenInFlight.current = true; setDraft(""); @@ -2116,7 +2117,9 @@ export default function Home() { try { let turn: ConversationalRectificationTurn; if (!resumeTarget) { - const durable = await durableRectificationQuestionHandoff.current.load(); + const durable = requestedQuestion !== null || localHandoff !== null + ? await durableRectificationQuestionHandoff.current.load() + : null; if (durable && durable.status !== "consumed") { turn = durable.turn; } else { @@ -2226,26 +2229,27 @@ export default function Home() { ? rectificationSession.updatedAt : timestamp(), }; - let sessionSyncFailed = false; - if (!reusingRectificationSession) { - try { - await persistSession(boundSession, "create"); - } catch { - sessionSyncFailed = true; - } - setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]); - } else if (boundSession !== rectificationSession) { - updateSession(rectificationSession.id, () => boundSession); - try { - await persistSession(boundSession); - } catch { - sessionSyncFailed = true; - } - } setRectificationInitialTurn(turn); setRectificationOpeningAssistantText(""); synchronizeRectificationQuestion(turn, sourceSession); - setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); + setComposerNotice(""); + if (!reusingRectificationSession) { + setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]); + void rectificationPersistence.current.enqueue( + boundSession.id, + () => persistSession(boundSession, "create"), + ).catch(() => { + setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); + }); + } else if (boundSession !== rectificationSession) { + updateSession(rectificationSession.id, () => boundSession); + void rectificationPersistence.current.enqueue( + boundSession.id, + () => persistSession(boundSession), + ).catch(() => { + setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); + }); + } } catch (caught) { const message = caught instanceof Error ? caught.message diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 277918ac..5fbb201a 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -559,6 +559,89 @@ function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }> return clock(Math.round((start + end) / 2)); } +function declaredRange(input: DeclaredBirthInput): Readonly<{ startTime: string; endTime: string }> { + if (input.source === "period_only") { + return { + early_morning: { startTime: "04:00", endTime: "07:59" }, + morning: { startTime: "08:00", endTime: "11:59" }, + afternoon: { startTime: "12:00", endTime: "17:59" }, + evening: { startTime: "18:00", endTime: "22:59" }, + late_night: { startTime: "23:00", endTime: "03:59" }, + }[input.reportedPeriod]; + } + if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" }; + if (input.source === "legacy_import" && !input.reportedTime) { + return input.reportedPeriod + ? declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod }) + : { startTime: "00:00", endTime: "23:59" }; + } + if (!input.reportedTime) throw new ConversationalRectificationError("profile_incomplete"); + const minute = (value: string) => { + const [hour = 0, part = 0] = value.split(":").map(Number); + return hour * 60 + part; + }; + const clock = (value: number) => { + const normalized = ((value % 1_440) + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; + }; + return { + startTime: clock(minute(input.reportedTime) - (input.uncertaintyBeforeMinutes ?? 2)), + endTime: clock(minute(input.reportedTime) + (input.uncertaintyAfterMinutes ?? 2)), + }; +} + +function openingRectificationState(input: { + readonly caseId: string; + readonly declaredBirthInput: DeclaredBirthInput; + readonly pendingConsultationQuestion: string | null; +}) { + const range = declaredRange(input.declaredBirthInput); + const representativeTime = midpointOfRange(range); + const calculationVersion = "rectification-opening-v1"; + const turn = conversationalRectificationTurnSchema.parse({ + caseId: input.caseId, + journeyProtocol: "conversational-evidence-v3", + status: "active", + turnVersion: 0, + narrative: "我们先从一件时间最明确、影响比较大的真实经历开始。请只说一件,并告诉我大约发生在哪一年、哪一月?", + candidate: { + status: "pending_validation", + representativeTime, + rangeStart: range.startTime, + rangeEnd: range.endTime, + }, + technicalReceipt: { + calculationVersion, + stableLayers: [], + sensitiveLayers: [], + candidateDifferenceRefs: [], + }, + evidenceRequest: { + domains: ["career", "education", "relocation", "relationship"], + datePrecision: "month_preferred", + freeTextAllowed: true, + prompt: "请说一件已经发生、时间比较明确的重要经历,并告诉我大约是哪一年、哪一月?", + followUp: { kind: "new_event", evidenceId: null }, + }, + evidenceRecap: [], + actions: ["answer", "pause", "abandon"], + pendingConsultationQuestion: input.pendingConsultationQuestion, + }); + const privateCandidate = privateCandidateSchema.parse({ + resultId: null, + representativeTime, + rangeStart: range.startTime, + rangeEnd: range.endTime, + calculationVersion, + workingState: { phase: "collecting_evidence", iteration: 0, notes: [] }, + }); + return { + turn, + privateCandidate, + validationReceipt: transitionReceipt("deterministic-rectification-opening"), + }; +} + type CorrectionResetReason = | "needs_clarification" | "non_scoreable" @@ -1193,33 +1276,10 @@ export function createConversationalRectificationService( }); reserved = reservation.billingState === "reserved"; if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null }; - const computed = await ports.buildTechnicalPacket({ - userId, + const opening = openingRectificationState({ caseId, - asOfDate: ports.asOfDate(), - declaredBirthInput: declared.data, - privateCandidate: null, - evidence: [], - }); - const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0); - const narrative = await generateRectificationNarrative({ - phase: "first", - packet: gatedPacket, - generator: ports.narrativeGenerator, - }); - const privateCandidate = privateCandidateFromPacket({ - packet: gatedPacket, - resultId: null, - iteration: 0, - forceCollecting: true, - }); - const firstTurn = turnFromNarrative({ - caseId, - turnVersion: 0, pendingConsultationQuestion: command.pendingConsultationQuestion ?? null, - packet: gatedPacket, - narrative, - evidence: [], + declaredBirthInput: declared.data, }); const created = await ports.store.createCaseWithFirstTurn({ userId, @@ -1229,9 +1289,9 @@ export function createConversationalRectificationService( revisionOfCaseId: profile.revisionOfCaseId, pendingConsultationQuestion: command.pendingConsultationQuestion ?? null, declaredBirthInput: declared.data, - firstTurn, - validationReceipt: narrative.validationReceipt, - privateCandidate, + firstTurn: opening.turn, + validationReceipt: opening.validationReceipt, + privateCandidate: opening.privateCandidate, }); observeCase(created, "unknown"); await ports.billing.complete({ diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 54bb5adf..8feafd1a 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -140,6 +140,32 @@ test("homepage birth-time card opens its dedicated session before the first turn assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*? { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const start = source.indexOf("async function openBirthTimeRectification"); + const end = source.indexOf("function handleConversationalRectificationTurn", start); + const handler = source.slice(start, end); + const turnVisible = handler.indexOf("setRectificationInitialTurn(turn)"); + const backgroundPersist = handler.indexOf("void rectificationPersistence.current.enqueue("); + + assert.ok(turnVisible >= 0); + assert.ok(backgroundPersist > turnVisible); + assert.match(handler, /void rectificationPersistence\.current\.enqueue\([\s\S]*?\(\) => persistSession\([\s\S]*?\.catch\(\(\) => \{[\s\S]*?校正已经开始,但会话关联暂时未同步到云端。/); +}); + +test("a direct homepage start skips the durable handoff read when no question was handed off", () => { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const start = source.indexOf("async function openBirthTimeRectification"); + const end = source.indexOf("function handleConversationalRectificationTurn", start); + const handler = source.slice(start, end); + + assert.match(handler, /const localHandoff = rectificationQuestionHandoff\.current\.peek\(\)/); + assert.match( + handler, + /const durable = requestedQuestion !== null \|\| localHandoff !== null\s*\? await durableRectificationQuestionHandoff\.current\.load\(\)\s*:\s*null/, + ); +}); + test("rectification cards render only inside the active rectification session", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 56c19a54..3ff1f8d6 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -211,6 +211,7 @@ type MutableCase = { }; function harness(options: { + readonly createFailure?: Error; readonly packetFailure?: Error; readonly packetFailureFromBuild?: number; readonly completeFailures?: number; @@ -330,6 +331,7 @@ function harness(options: { async createCaseWithFirstTurn(input) { events.push("create"); mutations.push("create"); + if (options.createFailure) throw options.createFailure; return replay(input.actionId, input, () => { const row = stored({ userId: input.userId, @@ -508,18 +510,30 @@ async function start(value: ReturnType, pendingConsultationQuest }); } -test("start validates profile, reads server price, reserves, computes, saves, then charges", async () => { +test("start creates a deterministic opening without scanning or narrative generation", async () => { const value = harness(); const turn = await start(value); - assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "narrative", "create", "complete"]); + assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "complete"]); + assert.equal(value.counts().packetBuilds, 0); assert.equal(turn.caseId, startActionId); assert.equal(turn.turnVersion, 0); assert.equal(turn.pendingConsultationQuestion, "我的工作何时变化?"); - assert.deepEqual(turn.technicalReceipt.sensitiveLayers, ["D9", "D10"]); + assert.equal(turn.candidate.status, "pending_validation"); + assert.equal(turn.candidate.representativeTime, "05:20"); + assert.equal(turn.candidate.rangeStart, "04:50"); + assert.equal(turn.candidate.rangeEnd, "05:50"); + assert.deepEqual(turn.technicalReceipt, { + calculationVersion: "rectification-opening-v1", + stableLayers: [], + sensitiveLayers: [], + candidateDifferenceRefs: [], + }); + assert.equal(turn.evidenceRequest?.followUp?.kind, "new_event"); assert.equal(JSON.stringify(turn).includes("candidateWeights"), false); assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId); assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58"); + assert.equal(value.cases.get(startActionId)?.row.validationReceipts[0]?.modelId, "deterministic-rectification-opening"); }); test("start rejects a client price before profile, billing, or calculation", async () => { @@ -533,11 +547,11 @@ test("start rejects a client price before profile, billing, or calculation", asy test("every post-reservation failure releases exactly once and never leaks its cause", async () => { const raw = "SQL model browser secret detail"; - const value = harness({ packetFailure: new Error(raw) }); + const value = harness({ createFailure: new Error(raw) }); await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError && error.code === "service_unavailable" && !error.message.includes(raw)); assert.equal(value.counts().releaseCount, 1); - assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "release"]); + assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "release"]); }); test("a start retry settles an existing reservation without reserving or computing again", async () => { @@ -550,7 +564,7 @@ test("a start retry settles an existing reservation without reserving or computi assert.equal(replayed.status, "active"); assert.equal(value.cases.get(startActionId)?.row.billingState, "charged"); - assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 }); + assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 }); assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]); }); @@ -568,7 +582,7 @@ test("a duplicate start with the same declared birth input reuses the unfinished assert.deepEqual(replayed, first); assert.deepEqual(value.counts(), countsBeforeRetry); assert.deepEqual(value.events, [ - "profile", "price", "reserve:9", "packet", "narrative", "create", "complete", + "profile", "price", "reserve:9", "create", "complete", "profile", "price", ]); }); @@ -595,7 +609,7 @@ test("a duplicate start with changed declared birth input remains a conflict", a }), (error: unknown) => error instanceof ConversationalRectificationError && error.code === "action_conflict"); assert.equal(value.counts().reserveCount, 1); - assert.equal(value.counts().packetBuilds, 1); + assert.equal(value.counts().packetBuilds, 0); }); test("a settlement failure releases its created case once and cannot replay as success", async () => { @@ -607,7 +621,7 @@ test("a settlement failure releases its created case once and cannot replay as s await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError && error.code === "billing_failed"); - assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 }); + assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 }); }); test("clear historical evidence is extracted, scored, narrated, recapped, and atomically saved", async () => { @@ -621,7 +635,7 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚", }); - assert.equal(value.counts().packetBuilds, 2); + assert.equal(value.counts().packetBuilds, 1); assert.equal(turn.status, "confirming"); assert.equal(turn.candidate.status, "ready_for_confirmation"); assert.equal(turn.turnVersion, 1); @@ -685,7 +699,7 @@ test("evidence corrections are append-only while recap and scoring use only the domain: "career", isCorrection: true, }]); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1, 1]); + assert.deepEqual(value.packetEvidenceCounts, [1, 1, 1]); }); test("uses Agent semantic classification when a single event falls through the deterministic keywords", async () => { @@ -732,7 +746,7 @@ test("repeated evidence remains auditable but identical date-domain-semantics sc const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; assert.equal(stored.length, 2, "both user submissions remain in the audit ledger"); assert.equal(repeated.evidenceRecap.length, 2); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1]); + assert.deepEqual(value.packetEvidenceCounts, [1, 1]); assert.deepEqual(value.packetEvidenceIds.at(-1), [firstId]); assert.equal(repeated.status, "active"); assert.equal(repeated.actions.includes("confirm"), false); @@ -771,13 +785,13 @@ test("an unclear correction immediately retires the wrong fact and stays retired value.cases.get(startActionId)?.row.privateCandidate.scoredHistoricalEvidence ?? [], [], ); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0]); + assert.deepEqual(value.packetEvidenceCounts, [1, 0]); const later = await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: thirdAnswerActionId, turnVersion: 2, answer: "2022年3月搬家", }); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0, 1]); + assert.deepEqual(value.packetEvidenceCounts, [1, 0, 1]); assert.equal(later.evidenceRecap.some((item) => item.id === wrongId), false); assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true); }); @@ -838,7 +852,7 @@ test("every non-confirmable correction rescans the declared range and withdraws }); test("a narrative fallback cannot discard a confirmation candidate produced by a valid correction", async () => { - const value = harness({ invalidNarrativeFromGeneration: 3 }); + const value = harness({ invalidNarrativeFromGeneration: 2 }); await start(value, null); await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, @@ -944,7 +958,6 @@ test("ordinary new evidence continues incrementally from the current candidate r }); assert.deepEqual(value.packetPrivateCandidates, [ - null, { rangeStart: "04:50", rangeEnd: "05:50", resultId: null }, { rangeStart: "05:16", rangeEnd: "05:20", resultId: null }, ]); @@ -999,7 +1012,7 @@ test("generic date uncertainty does not suppress clear historical evidence", asy assert.equal(turn.status, "active"); assert.equal(turn.candidate.status, "pending_validation"); - assert.equal(value.counts().packetBuilds, 2); + assert.equal(value.counts().packetBuilds, 1); assert.ok(value.events.includes("score-packet")); assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? []) .some((item) => item.eventSummary.includes("毕业") && item.scoreable === true)); @@ -1617,7 +1630,7 @@ test("the next evidence request moves past a domain the user already answered", }); test("a rejected intermediate narrative returns a retryable error without saving a template turn", async () => { - const value = harness({ invalidNarrativeFromGeneration: 2 }); + const value = harness({ invalidNarrativeFromGeneration: 1 }); await start(value, null); await assert.rejects(value.service.answer(userId, { @@ -1660,8 +1673,8 @@ test("one through three supported events save and narrate before the fourth accu assert.doesNotMatch(turn.narrative, /当前累计|本轮已纳入|本轮区分重点|下一步:/); } - assert.equal(value.counts().packetBuilds, 5); - assert.equal(value.events.filter((event) => event === "narrative").length, 5); + assert.equal(value.counts().packetBuilds, 4); + assert.equal(value.events.filter((event) => event === "narrative").length, 4); }); test("intermediate narrative receives the complete active event ledger", async () => { @@ -1827,7 +1840,7 @@ test("family evidence remains stored and public without changing its domain", as dateLabel: "2020-07", domain: "family", }]); - assert.deepEqual(value.packetEvidenceCounts, [0, 0]); + assert.deepEqual(value.packetEvidenceCounts, [0]); assert.equal(turn.status, "active"); }); @@ -1860,7 +1873,7 @@ test("three valid events plus pre-birth evidence wait until a later valid fourth assert.equal(preBirth?.extractionStatus, "needs_clarification"); assert.equal(stored?.eventEvidence.length, 5); assert.equal(latest?.evidenceRecap.length, 5); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 2, 3, 4]); + assert.deepEqual(value.packetEvidenceCounts, [1, 2, 2, 3, 4]); }); test("vague, future, and unmatched answers stay conversational and never score", async () => { @@ -1879,7 +1892,7 @@ test("vague, future, and unmatched answers stay conversational and never score", answer, ...(domain ? { domain } : {}), }); - assert.equal(value.counts().packetBuilds, 2); + assert.equal(value.counts().packetBuilds, 1); assert.equal(turn.status, "active"); assert.match(turn.narrative, /哪一年|哪一月|年月|已发生|已经发生|换个方向|未来/); assert.doesNotMatch(turn.narrative, /好的,我们不沿用不符合你的方向|已保存这段描述|我已保存你的原话|这条更正已保存/); @@ -1891,7 +1904,7 @@ test("vague, future, and unmatched answers stay conversational and never score", test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => { const value = harness({ packetFailure: new Error("synthetic packet outage"), - packetFailureFromBuild: 2, + packetFailureFromBuild: 1, }); const initial = await start(value, null); @@ -1947,7 +1960,7 @@ test("a lost-response retry replays the saved answer without rescoring or regene const before = [...value.events]; const replayed = await value.service.answer(userId, command); assert.deepEqual(replayed, first); - assert.equal(value.counts().packetBuilds, 2); + assert.equal(value.counts().packetBuilds, 1); assert.deepEqual(value.events, before); }); @@ -1990,7 +2003,7 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco }); test("a failed regenerate preserves the prior turn, evidence, candidate, and billing", async () => { - const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 3 }); + const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 2 }); await start(value, null); const answered = await value.service.answer(userId, { type: "answer", @@ -2044,7 +2057,7 @@ test("overlapping identical answers converge on the first receipt despite differ assert.deepEqual(second, first); assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2); - assert.equal(value.events.filter((event) => event === "narrative").length, 3); + assert.equal(value.events.filter((event) => event === "narrative").length, 2); assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2); });