fix(web): diagnose personal-report schema failures and ISO list timestamps

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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-22 20:46:30 +08:00
parent 4b3d82d987
commit 9762063453
13 changed files with 341 additions and 72 deletions
+16
View File
@@ -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 绑定失败走现有那一次 repairevidenceRefs 改为集合相等。`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
- 复发自:无
- 修复版本:待提交
+5 -2
View File
@@ -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,
};
}
@@ -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<QueryResult> {
+101 -20
View File
@@ -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<GeneratePersonalReportResult> {
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",
@@ -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,
@@ -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({
+45 -27
View File
@@ -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<PersonalReportAgentOutput>;
}>;
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;
}
@@ -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<string, unknown>;
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",
@@ -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);
});
@@ -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", () => {
@@ -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");
});
});
@@ -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 () => {
+10 -2
View File
@@ -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();