diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c83e8334..6d1725af 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3436,3 +3436,32 @@ - 防复发:所有可发布 Web 镜像必须在 `next build` 阶段注入与镜像/发布清单相同的完整 Git SHA;仅设置容器运行时变量无效。发布验收必须覆盖已登录页面和静态资源 deployment marker,不能只看 `/api/health`。 - 相关记录:`deploy/README.md`、`deploy/railway-web.Dockerfile`、staging/production exact-SHA release workflows - 修复版本:待提交(精确 SHA 以重新发布后的远端分支与 production health 为准) + +## BUG-205 | 普通咨询参数重试复用已拒绝计算缓存并误报运行合同未完成 + +- 状态:resolved(本地修复,未提交、未发布) +- 首次发现:2026-08-16 +- 最近更新:2026-08-16 +- 影响面:普通咨询 Mastra Agent 在同一 Agent 执行尝试内对 `run-jyotish-consultation` 的参数纠正、服务端运行合同补跑与计算 Promise 去重。 +- 用户现象:普通咨询运行较长时间后返回“Agent 未完成必要的方法与计算步骤,本次不会扣点”,实际主咨询 workflow 一次也没有执行。 +- 触发条件:模型首次同时传入 `domains` 与兼容字段 `theme`,触发 `invalid_consultation_domain_plan`;随后模型或服务端合同补跑改用合法 `theme: timing` 再次调用同一上下文绑定工具。 +- 根因:`createConsultationTools()` 在 `canonicalDomainPlan(input, ctx)` 参数校验前就创建并缓存 `calculation` Promise。首次非法调用产生的 rejected Promise 被永久保留;后续合法调用命中缓存后直接复用旧拒绝,导致 workflow 调用数保持为零,最终由既有合同门禁判定 `runtime_contract_incomplete`。 +- 修复:在读取或写入计算缓存前同步完成域计划校验,非法参数不启动、不计数也不污染缓存;实际计算 Promise 拒绝时仅清除仍指向该 Promise 的缓存,使后续调用可以重新执行。成功 Promise 继续保留,合法并发调用仍共享同一次服务器计算。未移除运行合同门禁,未伪造 workflow receipt 或成功状态。 +- 验证:新增 invalid `domains + theme` → valid `theme: timing` 回归,确认首次 workflow 调用数和工具调用计数均为零、第二次合法调用执行一次并完成合同;新增 workflow Promise 首次拒绝后后续调用不复用旧拒绝的回归;既有合法并发调用只计算一次测试继续通过。`frontend/tests/consultation-agentic-runtime.test.ts` 共 15/15 通过。 +- 防复发:所有可复用的请求级 Promise 必须先完成同步输入合同校验再缓存;rejected Promise 不得长期占用幂等缓存。并发去重测试必须同时覆盖合法并发、非法后合法重试和真实异步拒绝后的缓存释放。 +- 相关记录:BUG-186、BUG-189 +- 修复版本:本地未提交候选 + +## BUG-206 | 新用户完成初始化后首页生时校正打开失败被静默吞掉 + +- 状态:resolved(本地修复,未提交、未发布) +- 首次发现:2026-08-16 +- 最近更新:2026-08-16 +- 影响面:新用户完成出生资料初始化后,从首页“生时校正”卡片打开 V9 Agentic Rectification Case 的入口错误处理。 +- 用户现象:用户完成初始化资料后点击首页“生时校正”,页面没有切换,也没有显示任何错误,看起来像按钮没有反应。 +- 根因:`POST /api/rectification/cases/open` 返回 `profile_incomplete` 时,客户端仅在本地 `missingProfileStep(profile)` 非空时处理;新用户本地资料已被判断完整时该分支不更新任何 UI 状态便直接返回。网络异常同样只写入未渲染的 composer notice,导致 Case 未打开时没有可见反馈。 +- 修复:服务端 `profile_incomplete` 统一进入既有资料重新确认流程;`invalid_open_request` 与网络异常写入可见的 rectification error;首页入口附近增加 `role="alert"` 错误提示。成功响应后的 Case/Session 合并与校正界面切换逻辑保持不变。 +- 验证:新增修复前失败的首页 open 错误回归,锁定 profile incomplete、invalid request、网络异常和可见 alert;修复后聚焦入口测试 69/69 通过,`git diff --check` 通过。 +- 防复发:首页业务入口不得把 API 失败只写入未渲染状态;本地资料完整与服务端资料不一致时必须提供可见错误和恢复动作,不能静默返回。 +- 相关记录:BUG-177、BUG-198、BUG-199 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1ef6c0be..12c1e68e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -597,6 +597,7 @@ button:disabled { cursor: default; opacity: .45; } .product-entrypoint-footer small { min-width: 0; flex: 1; } .product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; } .product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; } +.rectification-entry-error { grid-column: 1 / -1; margin: 0; } .starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; } .starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; } .message-list { margin: 0 auto; width: min(900px, 100%); padding: var(--space-8) var(--space-8) var(--space-16); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6b41aba3..95e858e0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1118,6 +1118,9 @@ export default function Home() { }, ); const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction]; + const rectificationErrorMessage = rectificationError === "profile_incomplete" + ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" + : rectificationError; const onboardingFingerprint = onboardingProfileFingerprint(profile); useEffect(() => { @@ -2411,14 +2414,10 @@ export default function Home() { const payload = await response.json().catch(() => null); if (!response.ok) { const code = payload?.code; - if (code === "profile_incomplete" || code === "invalid_open_request") { - const step = missingProfileStep(profile); - if (step) { - setRectificationSessionId(null); - setRectificationCaseId(null); - setOnboardingStep(step); - setComposerNotice("请先完成出生资料,再开始生时校正。"); - } + if (code === "profile_incomplete") { + handleRectificationProfileIncomplete(); + } else if (code === "invalid_open_request") { + setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。"); } else if (code === "active_case_conflict") { setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); } else if (code === "case_session_not_found") { @@ -2466,7 +2465,7 @@ export default function Home() { void refreshRectificationEntrySummary(); return opened; } catch { - setComposerNotice("生时校正会话暂时无法打开,请稍后重试。"); + setRectificationError("生时校正会话暂时无法打开,请稍后重试。"); return null; } finally { rectificationOpenInFlight.current = false; @@ -3391,6 +3390,11 @@ export default function Home() { + {rectificationError && !rectificationSurfaceOpen && ( +

+ {rectificationErrorMessage} +

+ )}
diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 44a8a093..d728f463 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -186,13 +186,13 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { description: "Run one server-validated plan of up to six allowlisted personal Jyotish consultation domains. Use domains in priority order; birth data is server-bound and must never be supplied by the model.", inputSchema: consultationToolInputSchema, execute: async (input, context) => { + const domains = canonicalDomainPlan(input, ctx); if (calculation) return calculation; ctx.state.consultationToolStarted = true; ctx.state.consultationToolCallCount += 1; const startedAt = Date.now(); - calculation = (async () => { + const currentCalculation = (async () => { try { - const domains = canonicalDomainPlan(input, ctx); const userIntent = ctx.plan?.userIntent ?? input.question; await context.writer?.custom({ type: "data-jyotish-activity", @@ -250,7 +250,13 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { throw error; } })(); - return calculation; + calculation = currentCalculation; + try { + return await currentCalculation; + } catch (error) { + if (calculation === currentCalculation) calculation = null; + throw error; + } }, }); return { "run-jyotish-consultation": consultationTool }; diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index ddc86518..18b57aeb 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -159,6 +159,54 @@ test("domain plan enforces the raw plan upper bound and one input mode", async ( assert.equal(calls, 0); }); +test("invalid model input does not poison a later valid contract retry", async () => { + let calls = 0; + const state = createConsultationRuntimeState(); + const tool = createConsultationTools({ + userId: "u", sessionId: "s", requestId: "r-retry", consultationMode: "verified_chart", + serverChart, state, + runWorkflow: async (input) => { calls += 1; return workflow(input.theme); }, + })["run-jyotish-consultation"]; + const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; + + await assert.rejects( + tool.execute!({ question: "先给出错误参数", domains: ["career"], theme: "career" }, context), + /invalid_consultation_domain_plan/, + ); + assert.equal(calls, 0); + assert.equal(state.consultationToolCallCount, 0); + + const result = await tool.execute!({ question: "改用合法参数", theme: "timing" }, context) as { domains: string[] }; + assert.equal(calls, 1); + assert.deepEqual(result.domains, ["timing"]); + assert.equal(state.consultationToolCallCount, 1); + assert.equal(state.consultationToolCompleted, true); +}); + +test("a rejected workflow promise is cleared before a later tool call", async () => { + let calls = 0; + const state = createConsultationRuntimeState(); + const tool = createConsultationTools({ + userId: "u", sessionId: "s", requestId: "r-rejected-promise", consultationMode: "verified_chart", + serverChart, state, + runWorkflow: async (input) => { + calls += 1; + if (calls === 1) throw new Error("workflow_temporarily_failed"); + return workflow(input.theme); + }, + })["run-jyotish-consultation"]; + const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; + + await assert.rejects( + tool.execute!({ question: "第一次计算", theme: "career" }, context), + /workflow_temporarily_failed/, + ); + const result = await tool.execute!({ question: "重新计算", theme: "timing" }, context) as { domains: string[] }; + + assert.equal(calls, 2); + assert.deepEqual(result.domains, ["timing"]); +}); + test("personal Agent exposes the Jyotish Skill and named server tool", async () => { const state = createConsultationRuntimeState(); const agent = getJyotishAgent({ diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 20239568..bebabe63 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -102,6 +102,30 @@ test("server profile failures pause automatic rectification resume", () => { ); }); +test("homepage rectification open failures always surface a visible recovery message", () => { + const opening = page.slice( + page.indexOf("async function openRectificationCase"), + page.indexOf("async function openRectificationFromHomepage"), + ); + + assert.match( + opening, + /code === "profile_incomplete"[\s\S]*?handleRectificationProfileIncomplete\(\)/, + ); + assert.match( + opening, + /code === "invalid_open_request"[\s\S]*?setRectificationError\(/, + ); + assert.match( + opening, + /catch \{[\s\S]*?setRectificationError\("生时校正会话暂时无法打开,请稍后重试。"\)/, + ); + assert.match( + page, + /rectificationError && !rectificationSurfaceOpen && \([\s\S]*?role="alert"[\s\S]*?rectificationErrorMessage/, + ); +}); + test("account rehydration normalizes persisted ISO birth dates before completeness checks", () => { const profileReader = page.slice( page.indexOf("function readProfile"),