fix(frontend): recover rectification and consultation retries
Independent Staging Quality Gate / validate (push) Successful in 14m1s
Independent Staging Quality Gate / publish (push) Successful in 9m58s

This commit is contained in:
Jesse_Chen
2026-08-16 13:13:49 +08:00
parent 8ddcef4cf1
commit 9d8570640d
6 changed files with 124 additions and 12 deletions
+1
View File
@@ -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); }
+13 -9
View File
@@ -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() {
</div>
</article>
</section>
{rectificationError && !rectificationSurfaceOpen && (
<p className="form-error rectification-entry-error" role="alert">
{rectificationErrorMessage}
</p>
)}
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
+9 -3
View File
@@ -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 };
@@ -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<unknown>) => 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<unknown>) => 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({
@@ -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"),