fix(rectification): stop unwritten-evidence claims and same-cluster dasha false conflicts (BUG-635–640)
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Host only says 记下了 after a real write; Mastra schema rejections fail closed. Ledger year keys no longer drop quality probes, dual-dasha agreement is per cluster, width uses cluster span, and public house tables follow the inference minute.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 17:38:37 +08:00
co-authored by Cursor
parent 301827ad59
commit a998b6ec53
38 changed files with 1437 additions and 124 deletions
@@ -201,6 +201,8 @@ test("settled assistant body with a focus has no question-mark sentences", () =>
}]);
assert.doesNotMatch(linked[0]?.text ?? "", /[?]/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.evidenceNotRecorded));
assert.doesNotMatch(RECTIFICATION_USER_COPY.evidenceNotRecorded, /系统|模型/);
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /stripQuestionSentences/);
@@ -277,8 +277,10 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /composeHostFallbackNarration/);
assert.match(run, /errorCode: "empty_stream"/);
assert.match(run, /if \(errorCode === "empty_stream"\) return status === "retryable"/);
assert.match(run, /if \(errorCode === "evidence_not_written"\) return status === "retryable"/);
assert.doesNotMatch(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*empty_stream/);
assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*evidence_not_written/);
assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/);
assert.doesNotMatch(run, /heldSpoken/);
assert.match(run, /replace: true/);
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { indistinguishableWidthMinutes } from "../src/lib/rectification-agentic/v9/candidate-plateau.ts";
import {
SEALED_MINUTE_HOLDOUT,
buildConfirmationGate,
@@ -413,6 +414,22 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
assert.equal(safeToolErrorCode(new RectificationToolServiceError("confirmation_blocked")), "confirmation_blocked");
});
test("confirmation width uses cluster span so representative minutes cannot open the gate", () => {
const candidates = [
{ time: "04:53", rank: 1, tiedMinuteCount: 1, clusterStart: "04:50", clusterEnd: "04:55" },
{ time: "04:59", rank: 2, tiedMinuteCount: 1, clusterStart: "04:56", clusterEnd: "05:02" },
];
assert.equal(indistinguishableWidthMinutes(candidates), 13);
const gate = buildConfirmationGate({
engineConfirmationAllowed: true,
candidates,
decisionReceipt: { vedastro_minute_sensitive: { status: "passed" } },
});
assert.equal(gate.confirmation_allowed, false);
const adjacent = gate.blockers.find((item) => item.id === "adjacent_minutes_indistinguishable");
assert.equal(adjacent?.indistinguishable_width_minutes, 13);
});
test("collect_evidence keeps public can_adopt closed while selection_allowed follows the decision", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
@@ -8,6 +8,7 @@ import { decideRectification } from "../src/lib/rectification-agentic/core/recti
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { signFromTransitions } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { dashaAgreementAmongActive } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
import {
OPEN_ENGINE_CAPABILITY_CEILING,
@@ -93,6 +94,14 @@ function accidentInferenceState() {
};
}
function twelveHouses(sign: string) {
return Array.from({ length: 12 }, (_, index) => ({
house: index + 1,
sign,
occupants: [] as string[],
}));
}
function accidentProjection() {
return latestResultToolProjection({
resultId: RESULT_ID,
@@ -108,6 +117,18 @@ function accidentProjection() {
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
house_table: { time: "05:06", lagna: "狮子座", houses: twelveHouses("狮子座") },
house_tables_by_time: {
"04:53": { time: "04:53", lagna: "巨蟹座", houses: twelveHouses("巨蟹座") },
"05:06": { time: "05:06", lagna: "狮子座", houses: twelveHouses("狮子座") },
},
natal_recast: {
time: "05:06",
lagna: "狮子座",
user_meaning: "本命宫位已按 05:06 重算(上升 狮子座)。",
unique_minute_claim: false,
confirmation_allowed: false,
},
inference_state: accidentInferenceState(),
dasha_agreement: {
status: "conflict",
@@ -199,6 +220,35 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf
assert.match(report.markdown, /04:53 \| .*巨蟹座/);
});
test("same active cluster dasha tops agree even when the peak is not a representative", () => {
const engine = {
status: "conflict" as const,
vimshottari_top: "04:54",
narayana_top: "04:53",
user_meaning: "主限更偏向 04:54,分盘大运更偏向 04:53。冲突时不能按更高把握收口。",
};
const clusters = [{ time: "04:53", cluster_range: ["04:50", "04:55"] as const }];
const clustered = dashaAgreementAmongActive(engine, ["04:53"], clusters);
assert.equal(clustered?.status, "agree");
const minuteOnly = dashaAgreementAmongActive(engine, ["04:53"]);
assert.equal(minuteOnly?.status, "partial");
});
test("public house table follows the inference representative minute", () => {
const projection = accidentProjection();
assert.equal(projection.representative_time, "04:53");
assert.equal((projection.house_table as { time?: string } | undefined)?.time, "04:53");
const recast = projection.natal_recast as { time?: string; user_meaning?: string } | undefined;
assert.equal(recast?.time, "04:53");
assert.match(String(recast?.user_meaning), /04:53/);
assert.equal(projection.representative_time_pre_inference, "05:06");
assert.equal((projection.house_table_pre_inference as { time?: string } | undefined)?.time, "05:06");
const source = readFileSync(fileURLToPath(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url)), "utf8");
assert.match(source, /representative_time_pre_inference: _preTime/);
assert.match(source, /house_table_pre_inference: _preHouse/);
assert.match(source, /natal_recast_pre_inference: _preNatal/);
});
test("skill 10.0.21 forbids computing varga signs from transition times", () => {
const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url));
const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8");
@@ -10,7 +10,10 @@ import {
type CandidateSnapshotSource,
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import { askedEventProbeKeysFromLedgerEvidence, inspectDiscriminatorProbes } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { contrastPacketFromLatestResult } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { remainingReverseVerifyProbes } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { QUALITY_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import {
cachedEngineScoreIsReusable,
liveEngineScoringIdentityFromEnv,
@@ -163,6 +166,62 @@ test("anchored known_event_quality distinguish probes stay in the public packet"
);
});
test("ledger domain.year keys do not same-year-drop known_event_quality", () => {
const probe = qualityProbe({ information_gain: 0.8 });
const evidence = [{
status: "confirmed",
domain: "education",
occurredFrom: "2016-09-01",
occurredTo: null,
}];
const ledgerKeys = askedEventProbeKeysFromLedgerEvidence(evidence);
assert.deepEqual(ledgerKeys, ["education.2016"]);
const remaining = remainingReverseVerifyProbes(
[probe as never],
[{
id: "00000000-0000-4000-8000-000000000001",
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
}],
new Set(),
new Set(ledgerKeys),
"1997-08-08",
);
assert.equal(remaining[0]?.semantic_key, "education.2016.known_event_quality");
const inspected = inspectDiscriminatorProbes({
candidateSetVersion: "set-a",
vargaDifferences: [],
probes: [{
probeId: "probe:education.2016.known_event_quality",
candidateSetVersion: "set-a",
question: "2016 年 9 月那次上大学,更接近哪一种实际体验。",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] },
{ outcomeId: "no", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "quality-split",
informationGain: 0.8,
sourceFeatures: [{ technique: "known_event_quality", calculationResultId: null }],
domain: "education",
year: 2016,
semanticKey: "education.2016.known_event_quality",
choiceKind: "event_quality",
styleOptions: QUALITY_STYLE_OPTIONS.map((item) => ({
label: item.label,
answerClass: item.answer_class,
})),
}],
}, { askedKeys: ledgerKeys, topCandidateTimes: ["05:00", "05:07"] });
assert.equal(inspected.selected?.semanticKey, "education.2016.known_event_quality");
assert.equal(inspected.selected?.choiceKind, "event_quality");
});
test("quality answers are not inferred from event existence alone", () => {
const probe: ConflictProbe = {
id: "probe-quality",
@@ -3,8 +3,14 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
batchResultFromToolChunk,
composeHostFallbackNarration,
DEFAULT_RETRY_CONSTRAINT,
isToolInputRejection,
publicWriteToolCompleted,
retryConstraintForAttempt,
turnExpectsEvidenceWrite,
UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT,
} from "../src/lib/rectification-agentic/v9/host-fallback.ts";
test("host fallback recap uses only batch return lines", () => {
@@ -45,6 +51,32 @@ test("write-tool completion is limited to batch, set-focus and compare", () => {
assert.equal(publicWriteToolCompleted(failedBatch), false);
});
test("schema rejection envelopes are not treated as batch recaps or write completion", () => {
const envelope = {
error: true,
message: "Tool input validation failed",
validationErrors: { errors: [], fields: { kind: "debt" } },
};
assert.equal(isToolInputRejection(envelope), true);
assert.equal(composeHostFallbackNarration(envelope), null);
assert.equal(batchResultFromToolChunk({
type: "tool-result",
payload: { toolName: "rectification-record-evidence-batch", result: envelope },
}), null);
assert.equal(isToolInputRejection({ error: true, message: "no fields" }), false);
});
test("year fallback is gone; only explicit evidence expectedWrite triggers the guard", () => {
assert.equal(turnExpectsEvidenceWrite("opening", "evidence"), false);
assert.equal(turnExpectsEvidenceWrite("read_only", "none"), false);
assert.equal(turnExpectsEvidenceWrite("evidence", "evidence"), true);
assert.equal(turnExpectsEvidenceWrite("evidence", "none"), false);
assert.equal(turnExpectsEvidenceWrite("evidence", "unknown"), false);
assert.equal(turnExpectsEvidenceWrite("evidence", undefined), false);
assert.equal(retryConstraintForAttempt("evidence_not_written"), UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT);
assert.equal(retryConstraintForAttempt("empty_stream"), DEFAULT_RETRY_CONSTRAINT);
});
test("case snapshot receipts expose host_fallback origin from the durable phase", () => {
const route = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
@@ -18,9 +18,12 @@ import {
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { askedDiscriminatorKeys, askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { EXISTENCE_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import {
EXISTENCE_STYLE_OPTIONS,
QUALITY_STYLE_OPTIONS,
} from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
function existenceProbe(
domain: DiscriminatingEventProbe["domain"],
@@ -279,7 +282,19 @@ function contrastFromEvent(probe: DiscriminatingEventProbe): CandidateContrastPa
domain: probe.domain,
year: probe.year,
semanticKey: key,
choiceKind: "existence",
choiceKind: probe.choice_kind === "event_quality"
? "event_quality"
: probe.choice_kind === "varga_style"
? "varga_style"
: "existence",
...(probe.choice_kind === "event_quality"
? {
styleOptions: QUALITY_STYLE_OPTIONS.map((item) => ({
label: item.label,
answerClass: item.answer_class,
})),
}
: {}),
};
}
@@ -309,7 +324,11 @@ function conflictFromContrast(probe: CandidateContrastPacket["probes"][number]):
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: probe.informationGain,
source: probe.choiceKind === "varga_style" ? "varga_contrast" : "dasha_boundary",
source: probe.choiceKind === "event_quality"
? "known_event_quality"
: probe.choiceKind === "varga_style"
? "varga_contrast"
: "dasha_boundary",
choice_kind: probe.choiceKind ?? "existence",
style_options: probe.styleOptions
? probe.styleOptions.map((item) => ({
@@ -547,3 +566,80 @@ test("accident replay: fifth card is not the 2023 activation after four answers"
|| decided.sessionOutcome === "adopt_representative",
);
});
function educationQualityProbe(): DiscriminatingEventProbe {
return {
year: 2016,
year_label: "2016 年 9 月",
month: 9,
domain: "education",
event_family: "学业或考试发挥失常、压力特别大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "2016 年 9 月那次学业,更接近哪一种实际体验。",
role: "distinguish",
information_gain: 0.8,
semantic_key: "education.2016.known_event_quality",
candidate_split_hash: "education.2016.known_event_quality",
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
choice_kind: "event_quality",
style_options: [...QUALITY_STYLE_OPTIONS],
target_evidence_id: "e-edu",
};
}
test("ledger year keys drop existence probes but keep known_event_quality", () => {
const quality = educationQualityProbe();
const existence = existenceProbe("education", 2016, {
month: 9,
gain: 1.1,
key: "education.2016.09.dasha_boundary",
});
const ledgerAsked = askedDiscriminatorKeys(null, COVERED_FOR_DISCRIMINATE);
assert.ok(ledgerAsked.includes("education.2016"));
const remaining = remainingReverseVerifyProbes(
[quality, existence],
COVERED_FOR_DISCRIMINATE,
new Set(),
new Set(ledgerAsked),
"1997-08-08",
);
assert.equal(remaining.some((item) => item.semantic_key === quality.semantic_key), true);
assert.equal(remaining.some((item) => item.semantic_key === existence.semantic_key), false);
const inspected = inspectDiscriminatorProbes(
accidentPacket([contrastFromEvent(quality), contrastFromEvent(existence)]),
{ askedKeys: ledgerAsked, topCandidateTimes: ["05:00", "05:20"], birthDate: "1997-08-08" },
);
assert.equal(inspected.selected?.semanticKey, quality.semantic_key);
assert.equal(inspected.selected?.choiceKind, "event_quality");
assert.equal(
inspected.dropped.some((item) => (
item.semantic_key === existence.semantic_key && item.reason === "same_year_asked"
)),
true,
);
const askedAgain = inspectDiscriminatorProbes(
accidentPacket([contrastFromEvent(quality)]),
{
askedKeys: [...ledgerAsked, quality.semantic_key!],
topCandidateTimes: ["05:00", "05:20"],
birthDate: "1997-08-08",
},
);
assert.equal(askedAgain.selected, null);
assert.equal(
askedAgain.dropped.some((item) => (
item.semantic_key === quality.semantic_key && item.reason === "same_year_asked"
)),
true,
);
});
@@ -166,6 +166,15 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
chat,
/\(liveQuestionOnMessages \|\| questionGap === "persisted_question"\) && collectSpokenPrompt\n\s+\? "请回答上面的问题…"/,
);
// 原值: messages.some(同 focus_id)
// 新值: 只认 latestSettledAssistant
// 原因: BUG-635 决策 5,旧消息上的同焦点不再算仍在显示
assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/);
assert.doesNotMatch(chat, /const liveQuestionOnMessages = messages\.some\(/);
// 原值: const liveQuestionOnMessages = messages.some((message) =>
// 新值: latestSettledAssistant 上的 question.focus_id
// 原因: BUG-635 完成轮无问题行时,旧消息的同 focus_id 不得挡住主持人问题行
assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
@@ -7,6 +7,7 @@ import {
parseRectificationTurnIntent,
shouldContinueAgentForDatedEvent,
shouldDeclineCollectFocus,
expectedWriteFromCollectIntent,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts";
@@ -142,4 +143,31 @@ test("production intent handling contains no semantic regex or positional text p
assert.match(fastPath, /persistNextInterviewIfIdle/);
assert.match(fastPath, /!plan\.next_followup/);
assert.doesNotMatch(route, /classified\.answer_class!/);
assert.match(route, /classifyTurnIntentWithRetry/);
assert.match(route, /expectedWrite,/);
assert.equal(expectedWriteFromCollectIntent({
intent: "provide_new_evidence",
answer_class: null,
}), "evidence");
assert.equal(expectedWriteFromCollectIntent({
intent: "answer_current_focus",
answer_class: "yes",
has_new_dated_event: true,
}), "evidence");
assert.equal(expectedWriteFromCollectIntent({
intent: "answer_current_focus",
answer_class: "no",
}), "none");
assert.equal(expectedWriteFromCollectIntent(null), "none");
});
test("classifyTurnIntentWithRetry fails open as unknown after two misses", () => {
const src = readFileSync(
new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url),
"utf8",
);
assert.match(src, /export async function classifyTurnIntentWithRetry/);
assert.match(src, /for \(let attempt = 0; attempt < 2/);
assert.match(src, /expectedWrite: "unknown"/);
assert.doesNotMatch(src, /\(\?:19\|20\)\\d\{2\}/);
});
@@ -0,0 +1,346 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
batchResultFromToolChunk,
composeHostFallbackNarration,
isToolInputRejection,
publicWriteToolCompleted,
retryConstraintForAttempt,
turnExpectsEvidenceWrite,
UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT,
DEFAULT_RETRY_CONSTRAINT,
} from "../src/lib/rectification-agentic/v9/host-fallback.ts";
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
safePublicEvent,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { expectedWriteFromCollectIntent } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const REJECTION = {
error: true,
message: "Tool input validation failed",
validationErrors: { errors: ["kind"], fields: { kind: "debt" } },
};
type StreamChunk = {
type: string;
payload?: Record<string, unknown>;
object?: unknown;
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
return { type, ...(payload ? { payload } : {}) };
}
function readCaseThen(rest: StreamChunk[]): StreamChunk[] {
return [
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
...rest,
];
}
function fakeAgentStream(
chunks: StreamChunk[],
sink?: unknown[],
) {
return {
stream: async (messages: unknown[]) => {
sink?.push(messages);
return {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
},
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}) {
const emitted: Array<Record<string, unknown>> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const optionsValue: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2018 年 3 月开始欠债",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => { billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
emit: (event) => { emitted.push(event as Record<string, unknown>); },
buildAgent: async () => fakeAgentStream([]) as never,
...overrides,
};
return { options: optionsValue, emitted, billing, accounting };
}
function captureDiagnostics<T>(run: () => Promise<T>): Promise<{ result: T; logs: Record<string, unknown>[] }> {
const logs: Record<string, unknown>[] = [];
const original = console.info;
console.info = (...args: unknown[]) => {
const text = typeof args[0] === "string" ? args[0] : "";
if (text.includes("RectificationRunDiagnostic")) {
logs.push(JSON.parse(text) as Record<string, unknown>);
}
};
return run().then((result) => ({ result, logs })).finally(() => {
console.info = original;
});
}
test("collect intent maps provide_new_evidence and dated current-focus to expectedWrite", () => {
assert.equal(expectedWriteFromCollectIntent({
intent: "provide_new_evidence",
answer_class: null,
}), "evidence");
assert.equal(expectedWriteFromCollectIntent({
intent: "answer_current_focus",
answer_class: "yes",
has_new_dated_event: true,
}), "evidence");
assert.equal(expectedWriteFromCollectIntent({
intent: "answer_current_focus",
answer_class: "no",
}), "none");
assert.equal(expectedWriteFromCollectIntent(null), "none");
});
test("runner does not use a year-utterance fallback", () => {
assert.equal(turnExpectsEvidenceWrite("evidence", "evidence"), true);
assert.equal(turnExpectsEvidenceWrite("evidence", "none"), false);
assert.equal(turnExpectsEvidenceWrite("evidence", "unknown"), false);
assert.equal(turnExpectsEvidenceWrite("evidence", undefined), false);
assert.equal(turnExpectsEvidenceWrite("read_only", "evidence"), false);
assert.equal(turnExpectsEvidenceWrite("opening", "none"), false);
});
test("retry bootstrap uses the unwritten-evidence constraint only for that error", () => {
assert.equal(retryConstraintForAttempt("evidence_not_written"), UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT);
assert.equal(retryConstraintForAttempt("empty_stream"), DEFAULT_RETRY_CONSTRAINT);
assert.equal(retryConstraintForAttempt(null), DEFAULT_RETRY_CONSTRAINT);
});
test("route passes classifier expectedWrite into the agent runner", () => {
const route = readFileSync(
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
"utf8",
);
const agentRun = readFileSync(
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
"utf8",
);
assert.match(route, /classifyTurnIntentWithRetry/);
assert.match(route, /expectedWrite,/);
assert.match(route, /let expectedWrite: "evidence" \| "none" \| "unknown" = "none"/);
assert.doesNotMatch(route, /\(\?:19\|20\)\\d\{2\}/);
assert.doesNotMatch(agentRun, /\(\?:19\|20\)\\d\{2\}/);
});
test("unwritten 记下了 retries once then host-falls-back without billing", async () => {
let buildCount = 0;
const messages: unknown[] = [];
const { options, emitted, billing } = runOptions({
expectedWrite: "evidence",
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream(readCaseThen([
chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }),
chunk("finish"),
]), messages) as never;
},
});
const { result, logs } = await captureDiagnostics(() => runV9AgentTurn(options));
assert.equal(buildCount, 2);
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.evidenceNotRecorded);
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(result.phases.includes("billing.settled"), false);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.equal(emitted.some((event) => event.type === "billing.settled"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(
emitted.some((event) => event.type === "answer.delta" && event.replace === true && event.text === ""),
true,
);
const first = logs[0];
assert.equal(first?.stateMutationCommitted, false);
assert.equal(first?.expectedWrite, "evidence");
const retryBootstrap = JSON.stringify(messages[1] ?? messages[0]);
assert.match(retryBootstrap, /rectification-record-evidence-batch/);
assert.match(retryBootstrap, /记下了/);
});
test("read-case then batch then set-focus then 记下了 stays on the baseline path", async () => {
const { options, billing } = runOptions({
expectedWrite: "evidence",
buildAgent: async () => fakeAgentStream(readCaseThen([
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_recaps: [{ display_date_label: "2018年3月", event_phrase: "欠债" }] },
}),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }),
chunk("finish"),
])) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /记下了/);
assert.equal(result.phases.includes("answer.host_fallback"), false);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("empty body after a completed batch still uses BUG-633 host fallback", async () => {
const { options, billing } = runOptions({
expectedWrite: "evidence",
buildAgent: async () => fakeAgentStream(readCaseThen([
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_recaps: [{ display_date_label: "2018年3月", event_phrase: "欠债" }] },
}),
chunk("finish"),
])) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2018年3月 欠债。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("read_only action with a dated utterance does not trigger the write guard", async () => {
const { options, billing } = runOptions({
action: "read_only",
expectedWrite: "none",
message: "2018 年 3 月开始欠债",
buildAgent: async () => fakeAgentStream(readCaseThen([
chunk("text-delta", { text: "目前还在核对。" }),
chunk("finish"),
])) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "目前还在核对。");
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("schema rejection envelopes fail the tool and never count as a batch recap", () => {
const rejected = chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: REJECTION,
});
assert.equal(isToolInputRejection(REJECTION), true);
assert.deepEqual(
mapStreamChunkToActivity(rejected as never),
{
type: "tool.activity",
tool: "rectification-record-evidence-batch",
status: "failed",
code: "tool_call_rejected",
},
);
assert.equal(mapStreamChunkToPhase(rejected as never), null);
assert.equal(batchResultFromToolChunk(rejected), null);
assert.equal(composeHostFallbackNarration(REJECTION), null);
const publicEvent = safePublicEvent(mapStreamChunkToActivity(rejected as never));
assert.deepEqual(publicEvent, {
type: "tool.activity",
tool: "rectification-record-evidence-batch",
status: "failed",
code: "tool_call_rejected",
});
assert.doesNotMatch(JSON.stringify(publicEvent), /validationErrors/);
});
test("rejected batch tool-result is failed in the runner terminal status", async () => {
const { options, emitted } = runOptions({
expectedWrite: "evidence",
buildAgent: async () => fakeAgentStream(readCaseThen([
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: REJECTION,
}),
chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }),
chunk("finish"),
])) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(
emitted.some((event) => (
event.type === "tool.activity"
&& event.tool === "rectification-record-evidence-batch"
&& event.status === "failed"
&& event.code === "tool_call_rejected"
)),
true,
);
assert.equal(
emitted.some((event) => (
event.type === "tool.activity"
&& event.tool === "rectification-record-evidence-batch"
&& event.status === "completed"
)),
false,
);
assert.doesNotMatch(JSON.stringify(emitted), /validationErrors/);
const status = new Map<string, "completed" | "failed">([
["rectification-record-evidence-batch", "failed"],
]);
assert.equal(publicWriteToolCompleted(status), false);
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.evidenceNotRecorded);
});
test("chat live question only reads the last settled assistant message", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
// 原值: messages.some((message) => role/state/question/focus_id)
// 新值: latestSettledAssistant 且 question.focus_id 对应当前焦点
// 原因: BUG-635 旧消息上的同 focus_id 不再算仍在显示
assert.match(
chat,
/const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/,
);
assert.doesNotMatch(chat, /const liveQuestionOnMessages = messages\.some/);
const design = readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8");
assert.match(design, /completed turn that claimed to record evidence/);
});
@@ -918,7 +918,10 @@ test("empty stream without a write tool retries and the second attempt can succe
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "记下了这件事。" }),
// 原值: 「记下了这件事。」
// 新值: 「先继续核对。」
// 原因: BUG-635 把无写入的「记下了」收口成 evidence_not_written;本用例只锁 empty_stream 重试后成功。
chunk("text-delta", { text: "先继续核对。" }),
chunk("finish"),
]) as never;
},
@@ -926,7 +929,7 @@ test("empty stream without a write tool retries and the second attempt can succe
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 2);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了这件事。");
assert.equal(result.answerText, "先继续核对。");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.ok(!result.phases.includes("answer.host_fallback"));
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });