fix: stream validated rectification replies
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user