From 97620634536686dde89be2254098b024fe6b31c1 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 22 Aug 2026 20:46:30 +0800 Subject: [PATCH] fix(web): diagnose personal-report schema failures and ISO list timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep report_schema_invalid for the user, but record the inner check, retry plan bind once, and format self-hosted timestamptz so the report list no longer shows 时间未知. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 +++ frontend/src/app/api/reports/route.ts | 7 +- .../src/lib/db/local-postgres-client-core.ts | 27 ++-- .../src/lib/personal-report-generation.ts | 121 +++++++++++++++--- .../src/lib/personal-report-route-core.ts | 8 ++ .../src/lib/personal-report-worker-core.ts | 5 +- frontend/src/mastra/personal-report.ts | 72 +++++++---- .../tests/database-local-business.test.ts | 3 +- .../tests/local-postgres-query-value.test.ts | 23 ++++ frontend/tests/personal-report-api.test.ts | 17 +++ .../personal-report-generation-v2.test.ts | 93 +++++++++++++- .../tests/personal-report-generation.test.ts | 9 +- frontend/tests/personal-report-worker.test.ts | 12 +- 13 files changed, 341 insertions(+), 72 deletions(-) create mode 100644 frontend/tests/local-postgres-query-value.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9f4bd8a7..83410b0c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5323,3 +5323,19 @@ - 复发自:BUG-348(收集后再区分;区分问句被方法轮询和出牌 defer 排到采用之后) - 修复版本:9873ba42 +## BUG-352 | 个人报告失败被压成 report_schema_invalid,列表把 Date 显示成时间未知 + +- 状态:resolved +- 首次发现:2026-08-22 +- 最近更新:2026-08-22 +- 影响面:个人报告中心 `/reports`、`GET /api/reports`、`generatePersonalReport`、自托管 `timestamptz` 投影、报告 writer 绑定 +- 用户现象:生成完整报告后记录为「未完成」,右侧只显示 `report_schema_invalid`;卡片时间显示「时间未知」。出生时钟在创建时是可用的,失败发生在后台生成之后。 +- 触发条件:自托管 PostgreSQL 上创建个人完整报告;writer 输出与 section plan 不完全一致,或生成中被中止;列表读取 `created_at`。 +- 根因:`generatePersonalReport` 把 writer JSON、plan 绑定、组装、终态 parse 和 abort 全部吞成同一个 `report_schema_invalid`,且不记录内层 token。evidenceRefs 还要求顺序全等。自托管 `queryValue()` 只把 `date` 收成字符串,`timestamptz` 仍是 `Date`,列表投影只接受 string,UI 便显示「时间未知」。 +- 修复:schema 失败返回 allowlist `innerReason` 并打无 PII 日志;abort 向上抛出,不再伪装成 schema 失败。plan 绑定失败走现有那一次 repair;evidenceRefs 改为集合相等。`queryValue` 把 `timestamptz`/`timestamp` 收成 ISO,列表投影同时接受 `Date`。 +- 验证:`frontend/tests/personal-report-generation-v2.test.ts`、`frontend/tests/personal-report-generation.test.ts`、`frontend/tests/personal-report-api.test.ts`、`frontend/tests/local-postgres-query-value.test.ts`、`frontend/tests/personal-report-worker.test.ts`。 +- 防复发:用户可见码可以仍是 `report_schema_invalid`,但生成结果和日志必须带 allowlist 内层 token。lease abort 不得再断言成 schema 失败。列表时间必须能从 `Date` 或 ISO 字符串格式化。不得把模型原文、出生资料或路径写进失败日志。 +- 相关记录:BUG-154、BUG-188、BUG-217 +- 复发自:无 +- 修复版本:待提交 + diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 50b4ea47..e7ea8447 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -14,6 +14,7 @@ import { } from "@/lib/personal-report-entitlement"; import { resolveReportCreate, + reportListTimestamp, type ReportCreateCoreDeps, } from "@/lib/personal-report-route-core"; import { @@ -64,8 +65,10 @@ function listReportView(value: unknown) { : [], status: typeof row.status === "string" ? row.status : "failed", failureCode: typeof row.failure_code === "string" ? row.failure_code : null, - createdAt: typeof row.created_at === "string" ? row.created_at : "", - completedAt: typeof row.completed_at === "string" ? row.completed_at : null, + createdAt: reportListTimestamp(row.created_at), + completedAt: row.completed_at == null || row.completed_at === "" + ? null + : reportListTimestamp(row.completed_at) || null, }; } diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index f1091ab3..0ff6cf71 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -136,14 +136,25 @@ function databaseValue(type: string | undefined, value: unknown): unknown { return value; } -function queryValue(type: string | undefined, value: unknown): unknown { - if (type !== "date" || !(value instanceof Date)) return value; - // pg parses DATE at local midnight; UTC formatting can shift the calendar day. - return [ - String(value.getFullYear()).padStart(4, "0"), - String(value.getMonth() + 1).padStart(2, "0"), - String(value.getDate()).padStart(2, "0"), - ].join("-"); +export function queryValue(type: string | undefined, value: unknown): unknown { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return value; + if (type === "date") { + // pg parses DATE at local midnight; UTC formatting can shift the calendar day. + return [ + String(value.getFullYear()).padStart(4, "0"), + String(value.getMonth() + 1).padStart(2, "0"), + String(value.getDate()).padStart(2, "0"), + ].join("-"); + } + if ( + type === "timestamptz" + || type === "timestamp" + || type === "timestamp with time zone" + || type === "timestamp without time zone" + ) { + return value.toISOString(); + } + return value; } class LocalPostgresQueryBuilder implements PromiseLike { diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 192a1ad9..39a555b9 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -10,13 +10,14 @@ import type { ReportDocumentV1, ReportDocumentV2, } from "./personal-report-contract.ts"; -import type { - EvidenceRefStatus, - PersonalReportAgentOutput, - ReportAgentPort, - ReportEvidenceBundleV2, - ReportEvidencePacket, - ReportPlanetFact, +import { + PersonalReportAgentOutputError, + type EvidenceRefStatus, + type PersonalReportAgentOutput, + type ReportAgentPort, + type ReportEvidenceBundleV2, + type ReportEvidencePacket, + type ReportPlanetFact, } from "@/mastra/personal-report"; import type { EvidenceConflict, @@ -1433,8 +1434,11 @@ function uniqueInOrder(values: readonly string[]): string[] { return [...new Set(values)]; } -function equalStringArrays(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); +function equalStringSets(left: readonly string[], right: readonly string[]): boolean { + const uniqueLeft = [...new Set(left)].sort(); + const uniqueRight = [...new Set(right)].sort(); + return uniqueLeft.length === uniqueRight.length + && uniqueLeft.every((value, index) => value === uniqueRight[index]); } /** @@ -1463,7 +1467,7 @@ export function validatePersonalReportAgentOutputAgainstPlan( const card = claimCards.get(section.theme); if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`); if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`); - if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) { + if (!equalStringSets(section.evidenceRefs, sectionPlan.evidenceRefs)) { throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`); } if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) { @@ -2094,11 +2098,44 @@ export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readon depth: ReportDepth; }>; +export type ReportSchemaInnerReason = string; + export type GeneratePersonalReportResult = Readonly< | { status: "ready"; document: ReportDocumentV2; evidenceHash: string } - | { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" } + | { status: "failed"; failureCode: "report_schema_invalid"; innerReason: ReportSchemaInnerReason } + | { status: "failed"; failureCode: "report_guard_rejected" } >; +const SAFE_INNER_REASON = /^report_(?:writer|plan)_[a-z0-9_.:-]{0,80}$/; + +export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInnerReason { + if (error instanceof PersonalReportAgentOutputError) return "agent_output_invalid"; + if (error instanceof ReportEvidenceInsufficientError) return "assemble_invalid"; + const message = error instanceof Error ? error.message : ""; + if (SAFE_INNER_REASON.test(message)) return message; + const token = message.split(":")[0] ?? ""; + if (SAFE_INNER_REASON.test(token)) return token; + return "schema_invalid_unclassified"; +} + +export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean { + if (signal?.aborted) return true; + return error instanceof Error && error.name === "AbortError"; +} + +function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult { + console.info("[personal-report]", JSON.stringify({ + event: "generation_failed", + failureCode: "report_schema_invalid", + innerReason, + })); + return { status: "failed", failureCode: "report_schema_invalid", innerReason }; +} + +function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { + if (isPersonalReportGenerationAbort(error, signal)) throw error; +} + /** * Runs the dedicated report agent exactly once (plus its single internal * repair retry), assembles the candidate document, applies the deterministic @@ -2111,19 +2148,58 @@ export async function generatePersonalReport( deps: GeneratePersonalReportDeps, ): Promise { let bundle: ReportEvidenceBundleV2; - let plan: PersonalReportSectionPlan; - let agentOutput: PersonalReportAgentOutput; try { bundle = validateReportEvidenceBundleV2(deps.bundle); + } catch (error) { + rethrowIfAborted(error, deps.signal); + return failSchema("bundle_invalid"); + } + + let plan: PersonalReportSectionPlan; + try { plan = validatePersonalReportSectionPlan( buildPersonalReportSectionPlan(bundle, deps.depth), bundle, ); - agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal }); - validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle); - } catch { - return { status: "failed", failureCode: "report_schema_invalid" }; + } catch (error) { + rethrowIfAborted(error, deps.signal); + return failSchema("plan_invalid"); } + + const bindWriter = (output: PersonalReportAgentOutput) => ( + validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle) + ); + + let agentOutput: PersonalReportAgentOutput; + try { + agentOutput = await deps.agent.generate(bundle, plan, { + signal: deps.signal, + assertWriterOutput: bindWriter, + }); + } catch (error) { + rethrowIfAborted(error, deps.signal); + return failSchema( + error instanceof PersonalReportAgentOutputError + ? "agent_output_invalid" + : classifyReportSchemaInnerReason(error), + ); + } + + try { + agentOutput = bindWriter(agentOutput); + } catch (error) { + rethrowIfAborted(error, deps.signal); + try { + agentOutput = bindWriter(await deps.agent.generate(bundle, plan, { + signal: deps.signal, + assertWriterOutput: bindWriter, + })); + } catch (repairError) { + rethrowIfAborted(repairError, deps.signal); + return failSchema(classifyReportSchemaInnerReason(repairError)); + } + } + const packet = buildLegacyPacketFromBundle(bundle); let candidate: ReportDocumentV2; try { @@ -2135,8 +2211,13 @@ export async function generatePersonalReport( plan, agentOutput, }); - } catch { - return { status: "failed", failureCode: "report_schema_invalid" }; + } catch (error) { + rethrowIfAborted(error, deps.signal); + return failSchema( + error instanceof ReportEvidenceInsufficientError + ? "assemble_invalid" + : classifyReportSchemaInnerReason(error), + ); } const guarded = applyReportGuard(candidate, packet); if (!guarded.ok) { @@ -2144,7 +2225,7 @@ export async function generatePersonalReport( } const parsed = safeParseServerReportDocument(guarded.document); if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") { - return { status: "failed", failureCode: "report_schema_invalid" }; + return failSchema("final_parse_rejected"); } return { status: "ready", diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index ae2ccc5f..76ca6bc4 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -123,6 +123,14 @@ function parseBirthDate(value: unknown): { year: number; month: number; day: num return { year, month, day }; } +export function reportListTimestamp(value: unknown): string { + if (typeof value === "string") { + return Number.isFinite(Date.parse(value)) ? value : ""; + } + if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); + return ""; +} + export function reportView(row: PersonalReportRecord) { return { id: row.id, diff --git a/frontend/src/lib/personal-report-worker-core.ts b/frontend/src/lib/personal-report-worker-core.ts index 21e3166a..66d60ca4 100644 --- a/frontend/src/lib/personal-report-worker-core.ts +++ b/frontend/src/lib/personal-report-worker-core.ts @@ -325,7 +325,10 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) { await heartbeatChain; if (heartbeatError !== null) throw heartbeatError; if (generated.status === "failed") { - throw new PersonalReportWorkerError(generated.failureCode, false); + const innerReason = generated.failureCode === "report_schema_invalid" + ? generated.innerReason + : generated.failureCode; + throw new PersonalReportWorkerError(generated.failureCode, false, innerReason); } await deps.jobs.updateProgress({ diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index fafb4720..066ecf9c 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -181,6 +181,8 @@ ${JSON.stringify({ bundle, plan })}`; export type ReportAgentGenerateOptions = Readonly<{ signal?: AbortSignal; + /** Server-owned plan binding. Failure here consumes the single repair retry. */ + assertWriterOutput?: (output: PersonalReportAgentOutput) => void; }>; export type ReportAgentPort = Readonly<{ @@ -192,7 +194,12 @@ export type ReportAgentPort = Readonly<{ ): Promise; }>; -const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。"; +const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。"; + +function isAbortError(error: unknown, signal?: AbortSignal): boolean { + if (signal?.aborted) return true; + return error instanceof Error && error.name === "AbortError"; +} export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort { const agent = new Agent({ @@ -208,45 +215,56 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA const startedAt = Date.now(); const signal = options?.signal; const prompt = buildReportPrompt(bundle, plan); + const structuredOutput = { + schema: personalReportAgentOutputSchema, + jsonPromptInjection: "inline" as const, + }; let repairAttempted = false; + + const runOnce = (content: string) => agent.generate( + [{ role: "user", content }], + { abortSignal: signal, structuredOutput }, + ); + + const accept = (result: { object?: unknown; usage?: unknown }): + | { ok: true; data: PersonalReportAgentOutput } + | { ok: false; cause: "schema" | "bind"; error?: unknown } => { + const parsed = personalReportAgentOutputSchema.safeParse(result.object); + if (!parsed.success) return { ok: false, cause: "schema" }; + try { + options?.assertWriterOutput?.(parsed.data); + return { ok: true, data: parsed.data }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return { ok: false, cause: "bind", error }; + } + }; + try { - const first = await agent.generate( - [{ role: "user", content: prompt }], - { - abortSignal: signal, - structuredOutput: { - schema: personalReportAgentOutputSchema, - jsonPromptInjection: "inline", - }, - }, - ); - const firstParsed = personalReportAgentOutputSchema.safeParse(first.object); - if (firstParsed.success) { + const first = await runOnce(prompt); + const firstAccepted = accept(first); + if (firstAccepted.ok) { logTelemetry(model.id, startedAt, false, "resolved", first.usage); - return firstParsed.data; + return firstAccepted.data; } // Exactly one repair retry is allowed. A second failure is terminal. repairAttempted = true; - const repaired = await agent.generate( - [{ role: "user", content: `${prompt}${REPAIR_PROMPT_SUFFIX}` }], - { - abortSignal: signal, - structuredOutput: { - schema: personalReportAgentOutputSchema, - jsonPromptInjection: "inline", - }, - }, - ); - const repairedParsed = personalReportAgentOutputSchema.safeParse(repaired.object); - if (repairedParsed.success) { + const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`); + const repairedAccepted = accept(repaired); + if (repairedAccepted.ok) { logTelemetry(model.id, startedAt, true, "resolved", repaired.usage); - return repairedParsed.data; + return repairedAccepted.data; } logTelemetry(model.id, startedAt, true, "failed", repaired.usage); + if (repairedAccepted.cause === "bind" && repairedAccepted.error) { + throw repairedAccepted.error; + } throw new PersonalReportAgentOutputError(); } catch (error) { if (error instanceof PersonalReportAgentOutputError) throw error; + if (isAbortError(error, signal)) throw error; + if (error instanceof Error && error.message.startsWith("report_writer_")) throw error; logTelemetry(model.id, startedAt, repairAttempted, "failed", null); throw error; } diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index c8efb4f5..26cd94bd 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -304,7 +304,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic .single(); assert.equal(profile.error, null); const { created_at: createdAt, ...profileData } = profile.data as Record; - assert.ok(createdAt instanceof Date); + assert.equal(typeof createdAt, "string"); + assert.match(String(createdAt), /^\d{4}-\d{2}-\d{2}T/); assert.deepEqual(profileData, { id: userId, email: "local-user@example.com", diff --git a/frontend/tests/local-postgres-query-value.test.ts b/frontend/tests/local-postgres-query-value.test.ts new file mode 100644 index 00000000..f942540f --- /dev/null +++ b/frontend/tests/local-postgres-query-value.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { queryValue } from "../src/lib/db/local-postgres-client-core.ts"; + +test("queryValue keeps calendar dates in local civil form", () => { + assert.equal(queryValue("date", new Date(2026, 7, 22)), "2026-08-22"); +}); + +test("queryValue projects timestamptz and timestamp Date values as ISO strings", () => { + const instant = new Date("2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("timestamptz", instant), "2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("timestamp", instant), "2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("timestamp with time zone", instant), "2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("timestamp without time zone", instant), "2026-08-22T12:34:56.000Z"); +}); + +test("queryValue leaves non-date values unchanged", () => { + assert.equal(queryValue("timestamptz", "2026-08-22T12:34:56.000Z"), "2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("text", "hello"), "hello"); + const leftover = new Date("2026-08-22T12:34:56.000Z"); + assert.equal(queryValue("unknown", leftover), leftover); +}); diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 11784cd0..5be9b5ca 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -8,6 +8,7 @@ import { } from "../src/lib/personal-report-plan.ts"; import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts"; import { + reportListTimestamp, resolveReportCreate, resolveReportDelete, resolveReportRead, @@ -1075,6 +1076,22 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi ); assert.doesNotMatch(listColumns, /report_document|calculation_hash|evidence_hash/); assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/); + assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/); + assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/); +}); + +test("list timestamps accept Date objects and ISO strings from self-hosted postgres", () => { + assert.equal( + reportListTimestamp(new Date("2026-08-22T12:34:00.000Z")), + "2026-08-22T12:34:00.000Z", + ); + assert.equal(reportListTimestamp("2026-08-22T12:34:00.000Z"), "2026-08-22T12:34:00.000Z"); + assert.equal(reportListTimestamp(""), ""); + assert.equal(reportListTimestamp(null), ""); + assert.match(coreSource, /export function reportListTimestamp/); + assert.match(generationSource, /innerReason/); + assert.match(generationSource, /isPersonalReportGenerationAbort/); + assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/); }); test("stable error codes live in the dependency-free codes module", () => { diff --git a/frontend/tests/personal-report-generation-v2.test.ts b/frontend/tests/personal-report-generation-v2.test.ts index dbba1ae2..3ed435b8 100644 --- a/frontend/tests/personal-report-generation-v2.test.ts +++ b/frontend/tests/personal-report-generation-v2.test.ts @@ -13,6 +13,7 @@ import { type TechniqueExecutionReceipt, } from "../src/lib/report-evidence-bundle-v2.ts"; import { + classifyReportSchemaInnerReason, generatePersonalReport, type GeneratePersonalReportResult, } from "../src/lib/personal-report-generation.ts"; @@ -242,8 +243,14 @@ function readyV2(result: GeneratePersonalReportResult): ReportDocumentV2 { return result.document as ReportDocumentV2; } -function expectSchemaRejected(result: GeneratePersonalReportResult): void { - assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid" }); +function expectSchemaRejected(result: GeneratePersonalReportResult, innerReason?: string): void { + assert.equal(result.status, "failed"); + if (result.status !== "failed") return; + assert.equal(result.failureCode, "report_schema_invalid"); + if (result.failureCode !== "report_schema_invalid") return; + assert.equal(typeof result.innerReason, "string"); + assert.ok(result.innerReason.length > 0); + if (innerReason) assert.equal(result.innerReason, innerReason); } const fullThemes: readonly ThemeSpec[] = [ @@ -330,7 +337,10 @@ test("generatePersonalReport passes the worker lease signal to the writer and se assert.equal(observedSignal, controller.signal); controller.abort(new Error("lease_lost")); - assert.deepEqual(await pending, { status: "failed", failureCode: "report_schema_invalid" }); + await assert.rejects(pending, (error: unknown) => { + assert.equal(error instanceof Error && error.message === "lease_lost", true); + return true; + }); assert.equal(observedSignal?.aborted, true); }); @@ -344,7 +354,9 @@ test("production writer and worker keep the same signal on initial and repair mo "utf8", ); - assert.equal(agentSource.match(/abortSignal: signal/g)?.length, 2); + assert.match(agentSource, /abortSignal: signal/); + assert.match(agentSource, /assertWriterOutput/); + assert.match(agentSource, /runOnce\(`\$\{prompt\}\$\{REPAIR_PROMPT_SUFFIX\}`\)/); assert.match(agentSource, /const signal = options\?\.signal/); assert.match(workerSource, /generatePersonalReport\(\{[\s\S]*signal: context\.signal,[\s\S]*\}\)/); }); @@ -433,7 +445,7 @@ test("writer extra, duplicate and blocked themes are rejected", async (t) => { const result = await run(bundle, fakeWriter((inputBundle, plan) => ( entry.mutate(writerOutputFor(inputBundle, plan)) ))); - expectSchemaRejected(result); + expectSchemaRejected(result, "report_writer_theme_count_mismatch"); }); } }); @@ -481,6 +493,75 @@ test("writer section id, evidence refs and claim-status upgrades are rejected", } }); +test("writer evidence ref order differences still bind to the deterministic plan", async () => { + const bundle = makeBundle({ + themes: [{ + theme: "career", + section: "事业与方向", + refs: ["ev-tech-d1", "ev-tech-d10"], + assertionLevel: "single_system_inference", + }], + charts: [chart("D1"), chart("D10", 3)], + }); + const observed = { calls: 0, plan: null as PersonalReportSectionPlan | null }; + const result = await run(bundle, fakeWriter((inputBundle, plan) => { + const output = writerOutputFor(inputBundle, plan); + return { + ...output, + thematicNarrative: [{ + ...output.thematicNarrative[0], + evidenceRefs: [...output.thematicNarrative[0].evidenceRefs].reverse(), + }], + }; + }, observed)); + readyV2(result); + assert.equal(observed.calls, 1); +}); + +test("plan binding failure consumes the single writer repair retry", async () => { + const bundle = makeBundle({ + themes: [{ + theme: "career", + section: "事业与方向", + refs: ["ev-tech-d1", "ev-tech-d10"], + assertionLevel: "single_system_inference", + }], + charts: [chart("D1"), chart("D10", 3)], + }); + const observed = { calls: 0, plan: null as PersonalReportSectionPlan | null }; + const result = await run(bundle, fakeWriter((inputBundle, plan) => { + const output = writerOutputFor(inputBundle, plan); + if (observed.calls === 1) { + return { + ...output, + thematicNarrative: [...output.thematicNarrative, { + ...output.thematicNarrative[0], + id: "theme-health", + theme: "health", + }], + }; + } + return output; + }, observed)); + readyV2(result); + assert.equal(observed.calls, 2); +}); + +test("schema inner reasons stay on the allowlist and never include model text", () => { + assert.equal( + classifyReportSchemaInnerReason(new Error("report_writer_theme_count_mismatch")), + "report_writer_theme_count_mismatch", + ); + assert.equal( + classifyReportSchemaInnerReason(new Error("report_writer_section_id_mismatch:career")), + "report_writer_section_id_mismatch:career", + ); + assert.equal( + classifyReportSchemaInnerReason(new Error("Unexpected token in JSON at position 12")), + "schema_invalid_unclassified", + ); +}); + test("accepted birth time never creates a fake candidate range", async () => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }], @@ -526,6 +607,6 @@ test("deterministic guard rejection and final schema rejection remain distinct t }], }; })); - expectSchemaRejected(result); + expectSchemaRejected(result, "final_parse_rejected"); }); }); diff --git a/frontend/tests/personal-report-generation.test.ts b/frontend/tests/personal-report-generation.test.ts index 907172fb..edb4783a 100644 --- a/frontend/tests/personal-report-generation.test.ts +++ b/frontend/tests/personal-report-generation.test.ts @@ -777,7 +777,7 @@ test("generatePersonalReport fails with report_guard_rejected on guard rejection assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" }); }); -test("generatePersonalReport fails with report_schema_invalid when the final parse rejects", async () => { +test("generatePersonalReport fails with report_schema_invalid when the writer emits an unplanned theme", async () => { const bundle = bundleV2Fixture(); const blockedRef = bundle.evidenceRefs.find((ref) => ref.status === "blocked"); assert.ok(blockedRef); @@ -801,11 +801,10 @@ test("generatePersonalReport fails with report_schema_invalid when the final par }), { count: 0 }), now: () => new Date("2026-08-06T00:00:00.000Z"), }); - // The guard downgrades the section to blocked (all refs blocked); the - // blocked section still contains the deterministic phrase 必然, so the FINAL - // canonical server parse rejects it. Guard mutations are always re-validated. assert.equal(result.status, "failed"); - if (result.status === "failed") assert.equal(result.failureCode, "report_schema_invalid"); + if (result.status === "failed" && result.failureCode === "report_schema_invalid") { + assert.equal(result.innerReason, "report_writer_unplanned_theme:timing"); + } }); test("skill snapshot is the real packaged report manifest sha256, never the literal unknown", async () => { diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts index 3904f6c8..4e5e287a 100644 --- a/frontend/tests/personal-report-worker.test.ts +++ b/frontend/tests/personal-report-worker.test.ts @@ -476,7 +476,11 @@ test("a worker that loses its lease cannot atomically commit either terminal sta test("a worker that loses its lease cannot atomically fail either terminal state", async () => { const harness = createHarness({ loseLeaseBeforeCompletion: true, - generate: async () => ({ status: "failed", failureCode: "report_schema_invalid" }), + generate: async () => ({ + status: "failed", + failureCode: "report_schema_invalid", + innerReason: "agent_output_invalid", + }), }); const result = await harness.worker.tick(); @@ -490,7 +494,11 @@ test("a worker that loses its lease cannot atomically fail either terminal state test("invalid writer output atomically fails the report and lease-bound job", async () => { const harness = createHarness({ - generate: async () => ({ status: "failed", failureCode: "report_schema_invalid" }), + generate: async () => ({ + status: "failed", + failureCode: "report_schema_invalid", + innerReason: "agent_output_invalid", + }), }); const result = await harness.worker.tick();