fix: stream validated rectification replies

This commit is contained in:
Jesse_Chen
2026-07-23 15:45:42 +08:00
parent 0850619eaf
commit 73cafeb6e0
11 changed files with 348 additions and 26 deletions
@@ -116,8 +116,10 @@ test("homepage birth-time card opens its dedicated session before the first turn
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.match(handler, /type: "start",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
assert.match(handler, /type: "resume",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}/);
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}[\s\S]*?openingAssistantText=\{rectificationOpeningAssistantText\}/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -204,3 +204,33 @@ test("502 and non-JSON failures retry only once before one stable Chinese messag
assert.equal(bodies.length, 2);
assert.equal(bodies[0], bodies[1]);
});
test("client emits validated rectification narrative chunks before accepting the durable turn", async (context) => {
const chunks = ["已记录这段经历。", "接下来核对关系事件。"];
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
assert.match(String((init?.headers as Record<string, string>)?.Accept), /application\/x-ndjson/);
return new Response([
...chunks.map((text) => JSON.stringify({ type: "delta", text })),
JSON.stringify({ type: "turn", turn }),
"",
].join("\n"), {
status: 200,
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
});
const seen: string[] = [];
const result = await sendConversationalRectificationCommand({
type: "pause",
caseId,
actionId: firstActionId,
turnVersion: 4,
}, {
onNarrativeDelta(text) {
seen.push(text);
},
});
assert.deepEqual(seen, chunks);
assert.deepEqual(result, turn);
});
@@ -169,6 +169,31 @@ test("an uninitialized surface shows progress without a second start card", () =
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
});
test("the first Agent guidance streams into the empty rectification surface", () => {
const emptyController = controller({
turn: null,
pending: true,
getSnapshot: () => ({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{
controller: emptyController,
openingAssistantText: "先说一件已经发生、并且记得年月的重要经历。",
},
));
assert.match(markup, /先说一件已经发生、并且记得年月的重要经历/);
assert.doesNotMatch(markup, /正在建立校正记录/);
});
test("evidence is correctable, secondary controls stay hidden, and confirmation is explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
@@ -236,6 +261,13 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
ConversationalRectificationSurface,
{ controller: pendingController },
));
const streamingMarkup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller({
pending: true,
streamingAssistantText: "已记录关系事件,接下来核对开始时间。",
}) },
));
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
@@ -247,6 +279,8 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(markup, /Jyotisha 正在核对经历/);
assert.match(markup, /正在核对这段经历/);
assert.match(markup, /Jyotisha 正在分析/);
assert.match(streamingMarkup, /已记录关系事件,接下来核对开始时间/);
assert.doesNotMatch(streamingMarkup, /Jyotisha 正在分析/);
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
assert.match(markup, /aria-label="生时校正对话"/);
assert.match(markup, /role="alert"|aria-live="polite"/);
@@ -139,6 +139,37 @@ test("controller admits only one in-flight mutation and clears text only after s
assert.deepEqual(pendingChanges, [true, false]);
});
test("controller publishes streamed Agent text while the durable rectification turn is pending", async () => {
const request = deferred<ConversationalRectificationResponse>();
let emit: ((text: string) => void) | undefined;
const controller = createConversationalRectificationController({
initialTurn: activeTurn(),
createActionId: idFactory(),
send: async (_command, options) => {
emit = options?.onNarrativeDelta;
return request.promise;
},
});
controller.setDraft("2024 年 8 月结束一段重要感情");
const answer = controller.answer("relationship");
emit?.("已记录关系事件,");
emit?.("接下来核对开始时间。");
assert.equal(controller.getSnapshot().pending, true);
assert.equal(controller.getSnapshot().streamingAssistantText, "已记录关系事件,接下来核对开始时间。");
request.resolve({
...activeTurn(3),
narrative: "已记录关系事件,接下来核对开始时间。",
});
await answer;
assert.equal(controller.getSnapshot().pending, false);
assert.equal(controller.getSnapshot().streamingAssistantText, "");
assert.equal(controller.getSnapshot().messages?.at(-1)?.text, "已记录关系事件,接下来核对开始时间。");
});
test("controller retains alternating user and Agent messages after each answer", async () => {
const initial = activeTurn();
const controller = createConversationalRectificationController({
@@ -476,6 +507,7 @@ test("a case switch detaches the old mutation so the new case can mutate indepen
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
});
@@ -521,6 +553,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
});
@@ -533,6 +566,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
});
});
@@ -6,6 +6,7 @@ import {
createBirthTimeConversationPostHandler,
declaredBirthInputForLegacyCase,
loadProductionConversationalRectificationProfile,
streamConversationalRectificationResponse,
type BirthTimeConversationRouteService,
} from "../src/app/api/birth-time-conversation/route.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
@@ -21,6 +22,45 @@ const actionId = "00000000-0000-4000-8000-000000000712";
const caseId = "00000000-0000-4000-8000-000000000713";
const requestId = "00000000-0000-4000-8000-000000000714";
test("validated rectification responses stream narrative deltas before the durable turn", async () => {
const narrative = "已记录具体经历,并继续追问关系事件。";
const turn = {
caseId,
journeyProtocol: "conversational-evidence-v3" as const,
status: "active" as const,
turnVersion: 2,
narrative,
candidate: {
status: "pending_validation" as const,
representativeTime: "05:30",
rangeStart: "04:30",
rangeEnd: "06:30",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1" as const,
stableLayers: ["D1"],
sensitiveLayers: ["D9"],
candidateDifferenceRefs: ["relationship"],
},
evidenceRequest: {
domains: ["relationship" as const],
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
},
evidenceRecap: [],
actions: ["answer" as const],
pendingConsultationQuestion: null,
};
const response = streamConversationalRectificationResponse(turn, 0);
const lines = (await response.text()).trim().split("\n").map((line) => JSON.parse(line));
assert.match(response.headers.get("content-type") ?? "", /application\/x-ndjson/);
assert.equal(response.headers.get("x-accel-buffering"), "no");
assert.equal(lines.filter((event) => event.type === "delta").map((event) => event.text).join(""), narrative);
assert.deepEqual(lines.at(-1), { type: "turn", turn });
});
test("production narrator loads the Jyotish Skill without overriding packet truth", () => {
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");