fix(consult): BUG-950~953 Pass 4 按句放行,无分钟按句丢弃

正文不再整段 hold:闭合句立刻过 Pass 4,无 reject 即发(950)。
无出生分钟改为按句丢弃,全丢才用兜底句(951);该模式日期记 observe(952)。
校正流 token 级 thinking 死链按 P2 删除,测试翻转成否定合同(953)。
This commit is contained in:
jesse-ux
2026-09-18 14:08:45 +08:00
parent 24a6fc9873
commit cd4775aec6
14 changed files with 407 additions and 171 deletions
@@ -22,7 +22,6 @@ import {
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
import { acceptThinkingFragment } from "../../think-step-gate";
import { isToolInputRejection, toolResultFromChunk } from "./host-fallback";
export type PublicPhaseStreamEvent = Readonly<{
@@ -176,28 +175,11 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE
return null;
default:
// Raw reasoning, payloads, step internals and provider metadata stay off
// the answer channel. Chinese thinking is mapped separately.
// the public stream. Provider CoT is never mapped to an outward event.
return null;
}
}
export type InternalThinkingDeltaEvent = Readonly<{
type: "thinking.delta";
text: string;
}>;
export function toPublicThinkingDelta(text: string): InternalThinkingDeltaEvent | null {
const cleaned = acceptThinkingFragment(text);
if (!cleaned) return null;
return { type: "thinking.delta", text: cleaned };
}
export function mapStreamChunkToThinking(chunk: AgentChunkType): InternalThinkingDeltaEvent | null {
if (chunk.type !== "reasoning-delta") return null;
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
return toPublicThinkingDelta(text);
}
/**
* Project real public tool lifecycle events for the live UI. This stream is
* deliberately separate from the durable phase receipt: it never exposes
+100 -52
View File
@@ -14,7 +14,14 @@ import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
import { logTruncatedReasoning } from "./consultation-budget.ts";
import { acceptThinkStepText } from "./think-step-gate.ts";
import { applyPass4Policy, type Pass4Mode } from "./timing-output-guard.ts";
import {
classifyPass4,
takeClosedSentences,
GENERAL_NO_BIRTH_TIME_REFUSAL,
PASS4_RETRY_HINT,
type Pass4Mode,
type Pass4Step,
} from "./timing-output-guard.ts";
import {
applyThinkingSectionProgress,
generalConsultationThinkingPlan,
@@ -346,6 +353,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let fullOutput = "";
let thinkingText = "";
let planSent = false;
// Pass 4 buffers only the current open sentence. Closed sentences are
// classified and either sent whole or dropped whole. Whole-answer rewrite
// is allowed only before any answer.delta has gone out; after the first
// sentence is public, later rejects are dropped in place so the user never
// sees a flash-then-replace. That is the boundary between "don't flash
// twice" and "stream by sentence".
let pass4Buffer = "";
const startedAt = new Map<string, number>();
// A retry reuses these counters so a failure in either attempt is recorded once.
const toolErrors = { seen: 0 };
@@ -370,10 +384,51 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of planned) send(controller, event);
};
const pendingAnswer = () => fullOutput + pass4Buffer;
function recordPass4Steps(steps: readonly Pass4Step[]) {
for (const step of steps) {
const name = `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`;
if (options.state.steps.some((item) => item.name === name)) continue;
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name,
status: step.action === "observe" ? "completed" : "failed",
failureCode: step.kind,
});
}
}
async function releasePass4Sentences(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
text: string,
flush: boolean,
) {
if (!options.pass4Mode) return;
pass4Buffer += text;
const { closed, rest } = flush
? { closed: pass4Buffer ? [pass4Buffer] : [], rest: "" }
: takeClosedSentences(pass4Buffer);
pass4Buffer = rest;
for (const sentence of closed) {
if (!sentence) continue;
const steps = classifyPass4(sentence, options.pass4Mode);
recordPass4Steps(steps);
if (steps.some((step) => step.action === "reject")) continue;
if (!firstOutput && /\S/.test(sentence)) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: sentence });
fullOutput += sentence;
if (/\S/.test(sentence)) emitted = true;
}
}
async function consumeAttempt(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
stream: ChunkStream,
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean; holdAnswer?: boolean } = {},
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
) {
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
let held = "";
@@ -392,11 +447,16 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
composingSent = true;
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
}
if (options.pass4Mode) {
await releasePass4Sentences(controller, held, false);
held = "";
return;
}
if (!firstOutput && /\S/.test(held)) {
firstOutput = true;
await options.onFirstOutput?.();
}
if (!attempt.holdAnswer) send(controller, { type: "answer.delta", text: held });
send(controller, { type: "answer.delta", text: held });
fullOutput += held;
if (/\S/.test(held)) emitted = true;
held = "";
@@ -456,23 +516,21 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
async function continueCurrentAnswer(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
heading?: string,
holdAnswer = false,
) {
if (options.state.modelFinishReason !== "length") return;
if (!options.continueAfterLength) throw new Error("answer_truncated");
const beforeContinue = fullOutput;
const beforeContinue = pendingAnswer();
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-continue", status: "completed" });
send(controller, {
type: "activity",
phase: "answer-composition",
label: heading ? consultationWriteLabel(heading, true) : "正在组织回答",
});
await consumeAttempt(controller, await options.continueAfterLength(fullOutput), {
await consumeAttempt(controller, await options.continueAfterLength(pendingAnswer()), {
suppressCompositionActivity: true,
holdAnswer,
});
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
if (!/\S/.test(pendingAnswer())) throw new Error("empty_answer");
if (options.state.modelFinishReason === "length" && pendingAnswer() === beforeContinue) {
throw new Error("answer_truncated");
}
}
@@ -512,49 +570,40 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
return findings;
}
function recordPass4Steps(steps: ReturnType<typeof applyPass4Policy>["steps"]) {
const seen = new Set<string>();
for (const step of steps) {
const key = `${step.action}:${step.kind}`;
if (seen.has(key)) continue;
seen.add(key);
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`,
status: step.action === "observe" ? "completed" : "failed",
failureCode: step.kind,
});
}
}
async function finishPass4(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
origin: string,
findings: readonly ThinkFinding[],
allowComposeRetry = true,
) {
if (!options.pass4Mode) {
const produced = fullOutput.slice(origin.length);
if (produced) send(controller, { type: "answer.delta", text: produced });
return;
}
let report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode);
recordPass4Steps(report.steps);
if (report.retry && options.composeAnswer) {
if (!options.pass4Mode) return;
await releasePass4Sentences(controller, "", true);
const produced = () => fullOutput.slice(origin.length);
const hadRetryableReject = options.state.steps.some((step) =>
step.name === "pass4-reject:guarantee"
|| step.name === "pass4-reject:personal-chart"
|| step.name === "pass4-reject:methodology"
);
if (allowComposeRetry && !/\S/.test(produced()) && hadRetryableReject && options.composeAnswer) {
fullOutput = origin;
pass4Buffer = "";
await consumeAttempt(
controller,
await options.composeAnswer(findings, report.retryHint),
{ suppressCompositionActivity: true, holdAnswer: true },
await options.composeAnswer(findings, PASS4_RETRY_HINT),
{ suppressCompositionActivity: true },
);
await continueCurrentAnswer(controller, undefined, true);
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
recordPass4Steps(report.steps);
} else if (report.retry) {
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
recordPass4Steps(report.steps);
await continueCurrentAnswer(controller);
await releasePass4Sentences(controller, "", true);
}
if (!/\S/.test(produced()) && options.pass4Mode === "general_no_birth_time" && hadRetryableReject) {
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: GENERAL_NO_BIRTH_TIME_REFUSAL });
fullOutput = origin + GENERAL_NO_BIRTH_TIME_REFUSAL;
emitted = true;
}
fullOutput = origin + report.text;
if (report.text) send(controller, { type: "answer.delta", text: report.text });
}
async function composeOnce(
@@ -577,9 +626,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
await consumeAttempt(
controller,
await options.composeAnswer(findings),
{ suppressCompositionActivity: true, holdAnswer: Boolean(options.pass4Mode) },
{ suppressCompositionActivity: true },
);
await continueCurrentAnswer(controller, undefined, Boolean(options.pass4Mode));
await continueCurrentAnswer(controller);
await finishPass4(controller, origin, findings);
send(controller, {
type: "phase.completed",
@@ -602,32 +651,31 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of skillBoundEvents) send(controller, event);
flushThinkingPlan(controller);
try {
const holdMain = Boolean(options.pass4Mode) && !options.composeAnswer;
await consumeAttempt(controller, options.stream, {
drainSpoken: Boolean(options.composeAnswer),
holdAnswer: holdMain,
});
if (!contractReady(options) && options.retry) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" });
send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" });
await consumeAttempt(controller, await options.retry(), {
drainSpoken: Boolean(options.composeAnswer),
holdAnswer: holdMain,
});
}
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
const findings = await publishFindings(controller);
const composed = await composeOnce(controller, findings);
if (!composed) {
await continueCurrentAnswer(controller);
if (options.pass4Mode) await finishPass4(controller, "", findings);
}
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
await consumeAttempt(controller, await options.retryForAnswer(), { holdAnswer: holdMain });
const retryOrigin = fullOutput;
await consumeAttempt(controller, await options.retryForAnswer());
if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false);
}
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
if (!composed) {
await continueCurrentAnswer(controller, undefined, holdMain);
if (holdMain) await finishPass4(controller, "", findings);
}
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput);
-34
View File
@@ -15,37 +15,3 @@ export function acceptThinkStepText(text: string): string | null {
if (!SENTENCE_RE.test(trimmed)) return null;
return trimmed;
}
const TOOLISH_FRAGMENT_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i;
/**
* Rectification still publishes reasoning as fragments. This gate only accepts
* or rejects a chunk. It does not rewrite, strip English, or require 8 chars.
*/
export function acceptThinkingFragment(text: string): string | null {
const trimmed = text.replace(/\s+/g, " ").trim();
if (!trimmed) return null;
if (!CJK_RE.test(trimmed)) return null;
if (TOOLISH_FRAGMENT_RE.test(trimmed)) return null;
return trimmed;
}
export function createThinkingFragmentAssembler() {
let buffer = "";
return {
push(chunk: string): string | null {
const accepted = acceptThinkingFragment(chunk);
if (!accepted) return null;
buffer += accepted;
if (!SENTENCE_RE.test(buffer)) return null;
const released = buffer;
buffer = "";
return released;
},
flush(): string | null {
const leftover = buffer;
buffer = "";
return leftover ? acceptThinkingFragment(leftover) : null;
},
};
}
+41 -17
View File
@@ -162,14 +162,33 @@ export type Pass4Result = Readonly<{
retryHint?: string;
}>;
function dropGuaranteeClauses(text: string): string {
const parts = text.split(/([.!?\n]+)/u);
const SENTENCE_TERMINATOR = /[.!?\n]/u;
/**
* Pass 4 is a sentence gate, not a rewrite knife. A closed sentence is sent
* whole or dropped whole; nothing inside a sentence is rewritten.
*/
export function takeClosedSentences(buffer: string): { closed: string[]; rest: string } {
const closed: string[] = [];
let cursor = 0;
for (let index = 0; index < buffer.length; index += 1) {
if (!SENTENCE_TERMINATOR.test(buffer[index]!)) continue;
let end = index + 1;
while (end < buffer.length && SENTENCE_TERMINATOR.test(buffer[end]!)) end += 1;
closed.push(buffer.slice(cursor, end));
cursor = end;
index = end - 1;
}
return { closed, rest: buffer.slice(cursor) };
}
function dropRejectedClauses(text: string, mode: Pass4Mode): string {
const { closed, rest } = takeClosedSentences(text);
const pieces = rest ? [...closed, rest] : closed;
let output = "";
for (let index = 0; index < parts.length; index += 2) {
const clause = parts[index] ?? "";
const punct = parts[index + 1] ?? "";
if (detectPreciseTimingViolations(clause).some((item) => item.kind === "guarantee")) continue;
output += clause + punct;
for (const sentence of pieces) {
if (classifyPass4(sentence, mode).some((step) => step.action === "reject")) continue;
output += sentence;
}
return output;
}
@@ -181,9 +200,7 @@ export function classifyPass4(
const steps: Pass4Step[] = [];
for (const hit of detectPreciseTimingViolations(text)) {
if (hit.kind === "exact-timing") {
if (mode !== "general_no_birth_time") {
steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt });
}
steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt });
continue;
}
if (hit.kind === "guarantee") {
@@ -203,6 +220,13 @@ export function classifyPass4(
return steps;
}
export const PASS4_RETRY_HINT =
"不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。不要在正文写统一参数或技法审计表。";
function isRetryableReject(kind: TimingGuardKind) {
return kind === "guarantee" || kind === "personal-chart" || kind === "methodology";
}
export function applyPass4Policy(
text: string,
mode: Pass4Mode,
@@ -210,20 +234,20 @@ export function applyPass4Policy(
): Pass4Result {
const steps = classifyPass4(text, mode);
const rejects = steps.filter((step) => step.action === "reject");
if (!options?.secondPass && rejects.some((step) => step.kind === "guarantee" || step.kind === "personal-chart")) {
if (!options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) {
return {
text,
steps,
retry: true,
retryHint: "不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。",
retryHint: PASS4_RETRY_HINT,
};
}
let next = text;
if (options?.secondPass && rejects.some((step) => step.kind === "guarantee")) {
next = dropGuaranteeClauses(next);
}
if (options?.secondPass && mode === "general_no_birth_time" && rejects.some((step) => step.kind === "personal-chart")) {
next = GENERAL_NO_BIRTH_TIME_REFUSAL;
if (options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) {
next = dropRejectedClauses(next, mode);
if (!/\S/.test(next) && mode === "general_no_birth_time") {
next = GENERAL_NO_BIRTH_TIME_REFUSAL;
}
}
return { text: next, steps, retry: false };
}
@@ -1702,10 +1702,15 @@ test("composeAnswer length continue finishes the same body", async () => {
});
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
// 原值:三个日期句 hold 成 1 条 answer.delta
// 新值:每句闭合即发,≥3 条 answer.delta,日期不改写
// 原因:BUG-950 按句放行;exact-timing 是 observe,不得阻塞发送。
const state = toolOnlyRunState();
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } };
yield { type: "text-delta", payload: { text: "第一句先说方向。" } };
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日。" } };
yield { type: "text-delta", payload: { text: "第三句把区间说完。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
@@ -1719,13 +1724,16 @@ test("pass4 holds verified dates and records pass4-observe without rewriting", a
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.equal(answers.length, 1);
assert.match(answers[0] ?? "", /2013年11月21日/);
assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`);
assert.match(answers.join(""), /2013年11月21日/);
assert.doesNotMatch(answers.join(""), /具体时间已省略/);
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
});
test("pass4 retries compose once on guarantee then drops leftover clauses", async () => {
// 原值:整段 holdcompose 两次后一次发出
// 新值:按句放行,保证句从不出现在任何 answer.delta;已发出过句子不再整篇重写
// 原因:BUG-950,「不闪两次」只约束已发出的不撤回。
const state = toolOnlyRunState();
async function* first() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
@@ -1736,16 +1744,12 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
composeAnswer: async (_findings, retryHint) => {
composeAnswer: async () => {
composed += 1;
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
async function* body() {
yield {
type: "text-delta",
payload: {
text: "方向可以推进。我保证你一定会升职。",
},
};
yield { type: "text-delta", payload: { text: "方向可以推进。" } };
yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } };
yield { type: "text-delta", payload: { text: "第三句照常。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
return body();
@@ -1754,17 +1758,70 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(composed, 2);
const answer = events
assert.equal(composed, 1);
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向可以推进/);
assert.doesNotMatch(answer, /一定会升职/);
.map((event) => event.text);
assert.ok(answers.length >= 2, `expected ≥2 answer.delta, got ${answers.length}`);
assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false);
assert.match(answers.join(""), /方向可以推进/);
assert.match(answers.join(""), /第三句照常/);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true);
});
test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => {
// 原值:一句个人盘断言把整段换成拒绝句
// 新值:混合文本按句丢弃,知识句发出,个人盘句不发;全丢才用兜底句
// 原因:BUG-951。
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
yield { type: "text-delta", payload: { text: "第七宫在占星概念中常与关系相关。" } };
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
pass4Mode: "general_no_birth_time",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.match(answers.join(""), /第七宫在占星概念中常与关系相关/);
assert.equal(answers.some((text) => /你的上升是巨蟹座/.test(text)), false);
assert.doesNotMatch(answers.join(""), new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
});
test("pass4 releases each closed sentence and never emits a rejected clause", async () => {
const state = toolOnlyRunState();
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "第一句话。" } };
yield { type: "text-delta", payload: { text: "第二句话。" } };
yield { type: "text-delta", payload: { text: "第三句话。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`);
assert.equal(answers.join(""), "第一句话。第二句话。第三句话。");
});
test("pass4 general mode uses the refusal only after every sentence is dropped", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
@@ -1787,6 +1844,28 @@ test("pass4 general mode second-pass replaces personal chart claims with the ref
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
});
test("pass4 general mode observes dates while streaming the sentence", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
yield { type: "text-delta", payload: { text: "2026年8月适合观察方向。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
pass4Mode: "general_no_birth_time",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.match(answers.join(""), /2026年8月适合观察方向/);
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
});
test("natal tool success stores a Chinese thinking plan", async () => {
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
const encoded = JSON.stringify(state.thinkingPlan ?? []);
@@ -253,9 +253,9 @@ test("unverified notices grade by source without changing the output guards", ()
});
test("output guards and window/general instruction seams stay byte-stable", () => {
// 原值:createBirthTimeModeOutputGuard 在 general 模式替换个人盘句、窗口模式挖日期
// 新值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
// 原因:BUG-948,出生范围用户要能用应期,不能删字
// 原值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
// 新值:窗口日期原样;无分钟混合文本按句丢弃,知识句保留
// 原因:BUG-951,产品改按句丢弃,全丢才用兜底句
const modeSource = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
const routeSource = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
@@ -274,7 +274,13 @@ test("output guards and window/general instruction seams stay byte-stable", () =
"general_no_birth_time",
{ secondPass: true },
);
assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
// 原值:二次整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
// 新值:一般知识句保留,个人盘句丢掉,全丢才用兜底句
// 原因:BUG-951 按句丢弃。
assert.match(general.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.doesNotMatch(general.text, /你的上升是巨蟹座/);
assert.doesNotMatch(general.text, /。。/);
assert.doesNotMatch(general.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
const windowed = applyPass4Policy(
"Rahu 大运为 2013年11月21日 至 2031年11月22日。",
@@ -71,9 +71,9 @@ test("evidence-blocked unverified answers keep dates and only observe exact-timi
});
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
// 原值:恒等壳下的 transform 把个人盘句替换成拒绝句
// 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
// 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换
// 原值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
// 新值:按句丢弃后一般知识句仍在,个人盘句与保证句不在,无残留 `。。`;全丢才用兜底句
// 原因:BUG-951,产品同意无分钟模式改按句丢弃,恢复「preserving general knowledge」口径
const mixed = [
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。",
"忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。",
@@ -85,7 +85,11 @@ test("general mode deterministically rejects personal chart claims while preserv
const first = applyPass4Policy(mixed, "general_no_birth_time");
assert.equal(first.retry, true);
const second = applyPass4Policy(mixed, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.match(second.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.doesNotMatch(second.text, /基于你的盘|你的上升是巨蟹座|你的金星落在第七宫|D9 显示你适合晚婚|你的 D9:处女上升/);
assert.doesNotMatch(second.text, /一定会升职/);
assert.doesNotMatch(second.text, /。。/);
assert.doesNotMatch(second.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true);
});
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -7,8 +8,7 @@ import {
flushStepAnswerOnStreamFinish,
shouldPublishStepText,
} from "../src/lib/rectification-agentic/v9/step-answer.ts";
import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { createThinkingFragmentAssembler } from "../src/lib/think-step-gate.ts";
import { mapStreamChunkToPhase } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
function isPublicTool(name: string): boolean {
@@ -40,16 +40,29 @@ test("does not publish intermediate tool-step text as answer.delta", () => {
});
test("never publishes reasoning-delta to the browser", () => {
// 原值:mapStreamChunkToThinking 把中文 reasoning 变成 thinking.delta
// 新值:校正流对 reasoning-delta 必须丢弃;源码不得把它映射成对外事件
// 原因:BUG-953token 级 thinking 是死链且违反 P2provider reasoning 永不外发)
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "Let me" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("text-delta", { text: "Let me" }) as never), null);
assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never));
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核对经历。" }), isPublicTool).kind,
"none",
);
const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8");
assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/);
});
test("consecutive Chinese reasoning fragments assemble into a visible thinking line", () => {
const assembler = createThinkingFragmentAssembler();
assert.equal(assembler.push("The proposedKind value was rejected"), null);
assert.equal(assembler.push("先核"), null);
assert.equal(assembler.push("对经历。"), "先核对经历。");
// 原值:createThinkingFragmentAssembler 把「先核」+「对经历。」拼成可见思考行
// 新值:分片组装器删除;连续 reasoning-delta 仍全部丢弃
// 原因:BUG-953,翻转成否定合同,测试总数不降
const state = createStepAnswerState();
assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核" }), isPublicTool).kind, "none");
assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "对经历。" }), isPublicTool).kind, "none");
const gate = readFileSync(new URL("../src/lib/think-step-gate.ts", import.meta.url), "utf8");
assert.doesNotMatch(gate, /acceptThinkingFragment|createThinkingFragmentAssembler/);
});
test("publishes only the terminal no-tool step as assistant text", () => {
+6 -16
View File
@@ -5,8 +5,6 @@ import test from "node:test";
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
safePublicEvent,
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
@@ -173,20 +171,12 @@ test("reasoning, raw payloads, provider metadata and step internals never map to
});
test("Chinese thinking stays internal and is never a public stream event", () => {
assert.deepEqual(
mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
assert.equal(
mapStreamChunkToThinking(chunk("reasoning-delta", {
text: "The proposedKind value was rejected",
}) as never),
null,
);
assert.equal(
toPublicThinkingDelta("The proposedKind value was rejected because education is invalid"),
null,
);
// 原值:mapStreamChunkToThinking / toPublicThinkingDelta 把中文 reasoning 收成内部 thinking.delta
// 新值:源码不得存在这两个函数;reasoning-delta 不映射成对外事件;thinking.delta 仍被安全层丢掉
// 原因:BUG-953,死链按 P2 删除,翻转成否定合同
const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8");
assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never), null);
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }), null);
assert.equal(safePublicEvent({
type: "thinking.delta",
@@ -192,6 +192,17 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/);
});
test("general mode observes exact-timing without rewriting or blocking", () => {
// 原值:general_no_birth_time 下 exact-timing 既不记 observe 也不拦
// 新值:日期原文通过,回执有 observe
// 原因:BUG-952,最没有依据给日期的模式也要留痕。
const text = "2026年8月适合观察方向。";
const report = applyPass4Policy(text, "general_no_birth_time");
assert.equal(report.text, text);
assert.equal(report.retry, false);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
});
test("declared birth window keeps calculated dates as interval facts", () => {
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期。";
const report = applyPass4Policy(text, "declared_birth_window");