diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 701bdaca..5831723f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1459,3 +1459,18 @@ - 防复发:叙事模型只能影响自然语言表达,不能成为事件持久化和确定性收敛的单点故障;降级输出必须来自已验证 packet。 - 相关记录:BUG-067、BUG-075、BUG-077 - 修复版本:本次修复提交 + +## BUG-079 | 无可用路由领域时 Agent 追问导致 paused 会话返回 503 + +- 状态:resolved +- 首次发现:2026-07-26 +- 最近更新:2026-07-26 +- 影响面:生时校正开放叙事、暂停后恢复、历史事件保存、Narrative Agent 到公开 turn 的适配边界 +- 用户现象:完整 synthetic smoke 在暂停并恢复后提交下一条历史事件,技术评分和模型叙事都成功,但接口返回 `service_unavailable`,事件无法保存。 +- 触发条件:技术 packet 的 `suggestedDomains` 为空,而模型自然提出了一个可继续回答的问题。 +- 根因:Narrative Agent 会把模型领域替换成服务端允许的领域;允许集合为空时产生 `evidenceRequest.domains=[]`。叙事校验未拒绝空数组,但公开 turn 合同要求至少一个领域,导致 `turnFromNarrative()` 在持久化前把整轮转换为 503。 +- 修复:在 Narrative Agent 的共享适配边界统一处理 authored 与 legacy 输出;没有任何服务端可验证路由领域时保留自然语言正文,只把可选 `evidenceRequest` 归一化为 `null`。不放宽公开持久化 schema,也不信任模型自报领域。 +- 验证:回归覆盖无路由领域的 authored 输出,并覆盖 `pause → resume → answer` 完整编排路径;确认正文保留、`evidenceRequest=null`、事件正常保存且不返回 503。 +- 防复发:模型自然语言不得成为事件保存的硬依赖;私有路由元数据为空时应省略可选状态,而不是生成违反持久化合同的半合法对象。 +- 相关记录:BUG-075、BUG-078 +- 修复版本:本次修复提交 diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index b951f372..a7f373b6 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -168,16 +168,20 @@ function groundedEvidenceDomains( return grounded.length > 0 ? grounded : suggested; } +function groundedEvidenceRequest( + request: z.infer["evidenceRequest"], + packet: RectificationTechnicalPacket, +) { + if (request === null) return null; + const domains = groundedEvidenceDomains(request.domains, packet); + return domains.length > 0 ? { ...request, domains } : null; +} + function completeAuthoredOutput( output: z.infer, packet: RectificationTechnicalPacket, ): RectificationNarrativeModelOutput { - const evidenceRequest = output.evidenceRequest === null ? null : { - datePrecision: output.evidenceRequest.datePrecision, - prompt: output.evidenceRequest.prompt, - followUp: output.evidenceRequest.followUp, - domains: groundedEvidenceDomains(output.evidenceRequest.domains, packet), - }; + const evidenceRequest = groundedEvidenceRequest(output.evidenceRequest, packet); return { ...output, evidenceRequest, @@ -206,14 +210,10 @@ function parseModelOutput( if (legacy.success) { return { ...legacy.data, - evidenceRequest: legacy.data.evidenceRequest === null ? null : { - ...legacy.data.evidenceRequest, - // The next conversational topic is authored by the model, but the - // scoring-domain allowlist remains server-owned. Do not let a useful - // answer fail merely because the model described that topic with a - // different internal domain label. - domains: groundedEvidenceDomains(legacy.data.evidenceRequest.domains, packet), - }, + // The next conversational topic is authored by the model, but the + // scoring-domain allowlist remains server-owned. If no grounded routing + // domain exists, keep the prose and omit only the optional follow-up state. + evidenceRequest: groundedEvidenceRequest(legacy.data.evidenceRequest, packet), }; } return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet); diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index b4018e00..776c53eb 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -935,6 +935,28 @@ test("replaces legacy model-selected evidence domains instead of rejecting the a assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]); }); +test("keeps authored prose but drops follow-up state when no grounded routing domain exists", async () => { + const packet = { ...syntheticTechnicalPacket(), suggestedDomains: [] }; + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet, + generator: generator([{ + narrative: "这段经历已经记下。你愿意的话,可以继续讲当时发生了什么。", + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + prompt: "当时发生了什么?", + followUp: { kind: "new_event", evidenceId: null }, + }, + }]), + }); + + assert.equal(result.attempts, 1); + assert.equal(result.fallbackUsed, false); + assert.equal(result.output.evidenceRequest, null); + assert.match(result.narrative, /继续讲/); +}); + test("uses a fresh second provider request after the first attempt times out", async () => { let calls = 0; const signals: Array = []; diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 2788abb5..372ff745 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -1641,22 +1641,22 @@ test("the next evidence request moves past a domain the user already answered", assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/); }); -test("a rejected intermediate narrative returns a retryable error without saving a template turn", async () => { +test("a rejected intermediate narrative falls back without discarding the event", async () => { const value = harness({ invalidNarrativeFromGeneration: 1 }); await start(value, null); - await assert.rejects(value.service.answer(userId, { + const turn = await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, turnVersion: 0, answer: "2021年7月开始第一份长期工作", - }), (error: unknown) => error instanceof ConversationalRectificationError - && error.code === "service_unavailable"); + }); const stored = value.cases.get(startActionId)?.row; - assert.equal(stored?.turnVersion, 0); + assert.equal(turn.turnVersion, 1); + assert.match(turn.narrative, /按自己的节奏/); assert.equal(stored?.privateCandidate.resultId, null); - assert.equal(stored?.eventEvidence.length, 0); - assert.equal(stored?.validationReceipts.length, 1); - assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 0); + assert.equal(stored?.eventEvidence.length, 1); + assert.equal(stored?.validationReceipts.length, 2); + assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 1); }); test("one through three supported events save and narrate before the fourth accumulated event ranks", async () => { @@ -1785,6 +1785,39 @@ test("an unanswered suggested domain keeps a plateaued candidate conversational" assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]); }); +test("answer persists after pause when the packet has no grounded follow-up domain", async () => { + const value = harness({ + packetForEvidenceCount() { + return { ...packet(false), suggestedDomains: [] }; + }, + }); + const started = await start(value, null); + const paused = await value.service.pause(userId, { + type: "pause", + caseId: startActionId, + actionId: pauseActionId, + turnVersion: started.turnVersion, + }); + await value.service.resume(userId, { + type: "resume", + caseId: startActionId, + actionId: resumeActionId, + turnVersion: paused.turnVersion, + }); + + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: paused.turnVersion, + answer: "2020年4月进入研究院实习,10月主动辞职", + }); + + assert.equal(turn.status, "active"); + assert.equal(turn.evidenceRequest, null); + assert.match(turn.narrative, /记下了/); +}); + test("system-only blockers return a bounded result without waiting for another plateau", async () => { const value = harness({ packetForEvidenceCount(count) { @@ -2034,7 +2067,7 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco assert.match(value.narrativePrompts.at(-1) ?? "", /2012年12月正式退学/); }); -test("a failed regenerate preserves the prior turn, evidence, candidate, and billing", async () => { +test("a failed regenerate saves a fallback turn without changing evidence, candidate, or billing", async () => { const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 2 }); await start(value, null); const answered = await value.service.answer(userId, { @@ -2046,28 +2079,29 @@ test("a failed regenerate preserves the prior turn, evidence, candidate, and bil }); const storedBefore = value.cases.get(startActionId)?.row; assert.ok(storedBefore); - const snapshotBefore = structuredClone(storedBefore); + const evidenceBefore = structuredClone(storedBefore.eventEvidence); + const candidateBefore = structuredClone(storedBefore.privateCandidate); const countsBefore = value.counts(); const saveTurnsBefore = value.mutations.filter((mutation) => mutation === "saveTurn").length; - await assert.rejects(value.service.regenerate(userId, { + const regenerated = await value.service.regenerate(userId, { type: "regenerate", caseId: startActionId, actionId: laterActionId, turnVersion: answered.turnVersion, - }), (error: unknown) => error instanceof ConversationalRectificationError - && error.code === "service_unavailable"); + }); const storedAfter = value.cases.get(startActionId)?.row; - assert.deepEqual(storedAfter, snapshotBefore); - assert.equal(storedAfter?.turnVersion, answered.turnVersion); - assert.equal(storedAfter?.latestTurn.narrative, answered.narrative); + assert.equal(regenerated.turnVersion, answered.turnVersion + 1); + assert.match(regenerated.narrative, /按自己的节奏/); + assert.deepEqual(storedAfter?.eventEvidence, evidenceBefore); + assert.deepEqual(storedAfter?.privateCandidate, candidateBefore); assert.equal(value.counts().reserveCount, countsBefore.reserveCount); assert.equal(value.counts().releaseCount, countsBefore.releaseCount); assert.equal(value.counts().packetBuilds, countsBefore.packetBuilds + 1); assert.equal( value.mutations.filter((mutation) => mutation === "saveTurn").length, - saveTurnsBefore, + saveTurnsBefore + 1, ); });