fix(rectification): stop unwritten-evidence claims and same-cluster dasha false conflicts (BUG-635–640)
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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user