From 73cafeb6e0cd6b43dbf25c302bfaded8c5efc0fa Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 23 Jul 2026 15:40:12 +0800 Subject: [PATCH] fix: stream validated rectification replies --- docs/BUG_HISTORY.md | 18 +++- .../app/api/birth-time-conversation/route.ts | 52 ++++++++++- frontend/src/app/page.tsx | 13 +++ ...onversational-birth-time-rectification.tsx | 22 ++++- .../hooks/use-conversational-rectification.ts | 35 +++++-- .../conversational-rectification/client.ts | 92 ++++++++++++++++--- .../tests/consultation-entrypoint.test.ts | 4 +- ...onversational-rectification-client.test.ts | 30 ++++++ ...ersational-rectification-component.test.ts | 34 +++++++ ...rsational-rectification-controller.test.ts | 34 +++++++ ...conversational-rectification-route.test.ts | 40 ++++++++ 11 files changed, 348 insertions(+), 26 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3deded74..fd13a7b4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -648,4 +648,20 @@ - 防复发:消息历史必须携带来源可信度,事件 evidence 不得默认等价于聊天原文;跨服务的事件数量上限必须由同一契约测试锁定。 - 相关记录:BUG-034、BUG-037 - 复发自:BUG-037 -- 修复版本:待提交 +- 修复版本:0850619eaf5002736542463394720b0ab1949ce9 + +## BUG-039 | 生时校正 Agent 回答等待结束后一次性出现 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正首次进入、回答传输、Agent 生成状态、移动端对话可读性 +- 用户现象:首次进入时只显示“正在建立校正记录”,看不到后台生成的第一条 Agent 引导;用户发送经历后也只能看到“正在核对星盘信息”,待模型、校验和保存全部完成后,Agent 整段回答一次性出现,与普通 session 的逐段生成体验不一致。 +- 触发条件:任意生时校正 `start`、`resume` 或 `answer` 命令成功返回自然语言 narrative。 +- 根因:`/api/birth-time-conversation` 固定使用 `Response.json(turn)`,客户端也完整读取并校验 JSON 后才更新控制器;即使 narrative 已生成,传输层和聊天渲染层都没有增量事件契约。首次入口另由首页直接执行 `start/resume`,没有把生成中的首条 narrative 投影给尚未初始化的校正组件。 +- 修复:成功响应在客户端声明支持时改用 `application/x-ndjson`;服务端只对已经完成技法校验并持久化的 narrative 分块发送 `delta`,最后发送完整 durable turn。客户端逐行解析并即时投影到同一个 assistant 气泡,首次 `start/resume` 也把增量引导传入空白校正界面;最终 turn 到达后无缝转为 settled 历史。错误响应继续使用既有安全 JSON,已输出 delta 的中断不自动重放,避免重复文字。代理技法校验、事件事务和分钟安全门禁均保持在流输出之前。 +- 验证:新增 route 分块顺序与禁用代理缓冲回归、client NDJSON 增量解析回归、controller 在 durable turn 未到达前发布流式文本回归、组件 streaming 气泡回归;聚焦校正测试、真实 Chromium 390px 组件测试、ESLint、TypeScript 与 production build 通过。 +- 防复发:生时校正成功响应不得退回一次性 JSON 作为唯一前端路径;可见增量内容必须来自已校验 narrative,不得直接透传未完成或未通过约束的模型 token。 +- 相关记录:BUG-034、BUG-037、BUG-038 +- 复发自:无 +- 修复版本:本次流式修复提交 diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index c26ab2b9..c5084c54 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { conversationalRectificationCommandSchema, type ConversationalRectificationCommand, + type ConversationalRectificationResponse, type ConversationalRectificationTurn, } from "../../../lib/conversational-rectification/contracts.ts"; import { @@ -42,6 +43,53 @@ import { export const runtime = "nodejs"; export const maxDuration = 60; +const RECTIFICATION_STREAM_CONTENT_TYPE = "application/x-ndjson; charset=utf-8"; +const RECTIFICATION_STREAM_CHUNK_SIZE = 10; +const RECTIFICATION_STREAM_CHUNK_DELAY_MS = 18; + +function rectificationNarrativeChunks(narrative: string): string[] { + const characters = Array.from(narrative); + const chunks: string[] = []; + for (let index = 0; index < characters.length; index += RECTIFICATION_STREAM_CHUNK_SIZE) { + chunks.push(characters.slice(index, index + RECTIFICATION_STREAM_CHUNK_SIZE).join("")); + } + return chunks; +} + +export function streamConversationalRectificationResponse( + turn: ConversationalRectificationResponse, + chunkDelayMs = RECTIFICATION_STREAM_CHUNK_DELAY_MS, +): Response { + const encoder = new TextEncoder(); + let cancelled = false; + const body = new ReadableStream({ + async start(controller) { + try { + for (const text of rectificationNarrativeChunks(turn.narrative)) { + if (cancelled) return; + controller.enqueue(encoder.encode(`${JSON.stringify({ type: "delta", text })}\n`)); + if (chunkDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, chunkDelayMs)); + } + if (cancelled) return; + controller.enqueue(encoder.encode(`${JSON.stringify({ type: "turn", turn })}\n`)); + controller.close(); + } catch (error) { + if (!cancelled) controller.error(error); + } + }, + cancel() { + cancelled = true; + }, + }); + return new Response(body, { + headers: { + "Cache-Control": "no-cache, no-transform", + "Content-Type": RECTIFICATION_STREAM_CONTENT_TYPE, + "X-Accel-Buffering": "no", + }, + }); +} + const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); @@ -892,7 +940,9 @@ export function createBirthTimeConversationPostHandler( errorCategory: "none", deploymentSha, }); - return Response.json(turn); + return request.headers.get("accept")?.includes("application/x-ndjson") + ? streamConversationalRectificationResponse(turn) + : Response.json(turn); } catch (error) { const publicError = toConversationalRectificationPublicError(error); const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7bb218c3..e1d96a1c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -822,6 +822,7 @@ export default function Home() { const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState(""); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -1951,6 +1952,7 @@ export default function Home() { setDraftEntrypoint(null); setRectificationPendingQuestion(requestedQuestion); setRectificationInitialTurn(null); + setRectificationOpeningAssistantText(""); setRectificationError(""); setRectificationLoading(true); if (!reusingRectificationSession) { @@ -1975,6 +1977,10 @@ export default function Home() { type: "start", actionId: globalThis.crypto.randomUUID(), pendingConsultationQuestion: requestedQuestion, + }, { + onNarrativeDelta(text) { + setRectificationOpeningAssistantText((current) => current + text); + }, }); } else { let current = resumeTarget; @@ -2007,6 +2013,10 @@ export default function Home() { caseId: current.caseId, actionId: globalThis.crypto.randomUUID(), turnVersion: current.turnVersion, + }, { + onNarrativeDelta(text) { + setRectificationOpeningAssistantText((value) => value + text); + }, }); } } @@ -2031,6 +2041,7 @@ export default function Home() { } } setRectificationInitialTurn(turn); + setRectificationOpeningAssistantText(""); synchronizeRectificationQuestion(turn, sourceSession); setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); } catch (caught) { @@ -2038,6 +2049,7 @@ export default function Home() { ? caught.message : "生时校正暂时无法继续,请稍后重试。"; setRectificationError(message); + setRectificationOpeningAssistantText(""); setComposerNotice(message); if (!reusingRectificationSession) { setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); @@ -3084,6 +3096,7 @@ export default function Home() { ) : ( void; @@ -36,6 +37,7 @@ function candidateStatus(turn: ConversationalRectificationTurn): string { export function ConversationalRectificationSurface({ controller, + openingAssistantText = "", pendingConsultationQuestion, continuationPending = false, onContinueOriginalQuestion, @@ -58,7 +60,14 @@ export function ConversationalRectificationSurface({ if (!turn) { return (
-

正在建立校正记录…

+ {openingAssistantText + ? + :

正在建立校正记录…

} {controller.error &&

{controller.error}

}
); @@ -159,7 +168,14 @@ export function ConversationalRectificationSurface({ )} {controller.pending && canAnswer && ( - + controller.streamingAssistantText + ? + : )} {turn.status === "paused" && } {turn.status === "abandoned" && } @@ -253,6 +269,7 @@ export function ConversationalRectificationSurface({ type ConversationalBirthTimeRectificationProps = Readonly<{ initialTurn?: ConversationalRectificationResponse | null; + openingAssistantText?: string; pendingConsultationQuestion?: string | null; continuationPending?: boolean; onTurn?: (turn: ConversationalRectificationResponse) => void; @@ -274,6 +291,7 @@ export function ConversationalBirthTimeRectification(props: ConversationalBirthT return ( ; @@ -78,7 +80,10 @@ export type ConversationalRectificationController = ConversationalRectificationC type ControllerInput = Readonly<{ initialTurn?: ConversationalRectificationResponse | null; - send?: (command: ConversationalRectificationCommand) => Promise; + send?: ( + command: ConversationalRectificationCommand, + options?: ConversationalRectificationStreamOptions, + ) => Promise; createActionId?: () => string; onTurn?: (turn: ConversationalRectificationResponse) => void; onPendingChange?: (pending: boolean) => void; @@ -103,8 +108,8 @@ function createLatestControllerInput(initial: ControllerInput) { update(next: ControllerInput) { current = next; }, - send(command: ConversationalRectificationCommand) { - return (current.send ?? sendConversationalRectificationCommand)(command); + send(command: ConversationalRectificationCommand, options?: ConversationalRectificationStreamOptions) { + return (current.send ?? sendConversationalRectificationCommand)(command, options); }, onTurn(turn: ConversationalRectificationResponse) { current.onTurn?.(turn); @@ -140,6 +145,7 @@ export function createConversationalRectificationController( selectedDomain: null, correctionTarget: null, pending: false, + streamingAssistantText: "", error: "", }; let activeMutation: ActiveMutation | null = null; @@ -190,6 +196,7 @@ export function createConversationalRectificationController( ] : snapshot.messages, error: "", + streamingAssistantText: "", selectedDomain, correctionTarget, ...(clearDraft ? { draft: "" } : {}), @@ -214,7 +221,7 @@ export function createConversationalRectificationController( })); const run = (mutation: Mutation): MutationResult => { if (activeMutation?.caseContext === caseContext) return activeMutation.promise; - patch({ error: "" }); + patch({ error: "", streamingAssistantText: "" }); setPending(true); const turnAtStart = snapshot.turn; const caseContextAtStart = caseContext; @@ -223,7 +230,12 @@ export function createConversationalRectificationController( && activeMutation?.token === mutationToken; const operation = registry.run( mutation.identity, - (actionId) => send(mutation.command(actionId)), + (actionId) => send(mutation.command(actionId), { + onNarrativeDelta(text) { + if (!ownsCurrentContext()) return; + patch({ streamingAssistantText: (snapshot.streamingAssistantText ?? "") + text }); + }, + }), ).then((turn) => acceptTurn( turn, mutation.clearDraftOnSuccess === true, @@ -236,17 +248,20 @@ export function createConversationalRectificationController( const recovered = await recoverLatest(turnAtStart); return acceptTurn(recovered, false, caseContextAtStart); } catch (recoveryError) { - if (ownsCurrentContext()) patch({ error: displayError(recoveryError) }); + if (ownsCurrentContext()) patch({ error: displayError(recoveryError), streamingAssistantText: "" }); throw recoveryError; } } - if (ownsCurrentContext()) patch({ error: displayError(error) }); + if (ownsCurrentContext()) patch({ error: displayError(error), streamingAssistantText: "" }); throw error; }) .finally(() => { if (activeMutation?.token !== mutationToken) return; activeMutation = null; - if (caseContext === caseContextAtStart) setPending(false); + if (caseContext === caseContextAtStart) { + patch({ streamingAssistantText: "" }); + setPending(false); + } }); activeMutation = { caseContext: caseContextAtStart, @@ -288,6 +303,7 @@ export function createConversationalRectificationController( get selectedDomain() { return snapshot.selectedDomain; }, get correctionTarget() { return snapshot.correctionTarget; }, get pending() { return snapshot.pending; }, + get streamingAssistantText() { return snapshot.streamingAssistantText; }, get error() { return snapshot.error; }, getSnapshot: () => snapshot, subscribe(listener: () => void) { @@ -312,6 +328,7 @@ export function createConversationalRectificationController( selectedDomain: null, correctionTarget: null, pending: false, + streamingAssistantText: "", error: "", }); return; @@ -331,6 +348,7 @@ export function createConversationalRectificationController( selectedDomain: null, correctionTarget: null, pending: false, + streamingAssistantText: "", error: "", }); return; @@ -345,6 +363,7 @@ export function createConversationalRectificationController( { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, ], error: "", + streamingAssistantText: "", selectedDomain: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) ? snapshot.selectedDomain diff --git a/frontend/src/lib/conversational-rectification/client.ts b/frontend/src/lib/conversational-rectification/client.ts index 89838c92..bd5a7db0 100644 --- a/frontend/src/lib/conversational-rectification/client.ts +++ b/frontend/src/lib/conversational-rectification/client.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { postJson } from "../birth-time-client-transport.ts"; import { conversationalRectificationCommandSchema, conversationalRectificationResponseSchema, @@ -14,6 +13,18 @@ const publicErrorSchema = z.object({ message: z.string(), }).passthrough(); +const streamEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("delta"), text: z.string() }).strict(), + z.object({ + type: z.literal("turn"), + turn: conversationalRectificationResponseSchema, + }).strict(), +]); + +export type ConversationalRectificationStreamOptions = Readonly<{ + onNarrativeDelta?: (text: string) => void; +}>; + export class ConversationalRectificationRequestError extends Error { readonly name = "ConversationalRectificationRequestError"; readonly status: number; @@ -95,21 +106,75 @@ function isRetryableTransportError(error: unknown): boolean { ); } -async function postCommandWithOneReplay(body: string) { +async function readJsonPayload(response: Response): Promise { + return response.json().catch(() => null); +} + +async function readStreamedTurn( + response: Response, + options: ConversationalRectificationStreamOptions, +): Promise { + if (!response.body) throw new SyntaxError("missing rectification response stream"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + let turn: ConversationalRectificationResponse | null = null; + const consumeLine = (line: string) => { + if (!line.trim()) return; + const event = streamEventSchema.parse(JSON.parse(line)); + if (event.type === "delta") options.onNarrativeDelta?.(event.text); + else turn = event.turn; + }; + while (true) { + const { done, value } = await reader.read(); + buffered += decoder.decode(value, { stream: !done }); + let newline = buffered.indexOf("\n"); + while (newline >= 0) { + consumeLine(buffered.slice(0, newline)); + buffered = buffered.slice(newline + 1); + newline = buffered.indexOf("\n"); + } + if (done) break; + } + consumeLine(buffered); + if (!turn) throw new SyntaxError("missing rectification turn event"); + return turn; +} + +async function postCommandWithOneReplay( + body: string, + options: ConversationalRectificationStreamOptions, +) { for (let attempt = 0; attempt < 2; attempt += 1) { + let emittedNarrative = false; try { - const result = await postJson({ - url: "/api/birth-time-conversation", + const response = await fetch("/api/birth-time-conversation", { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/x-ndjson, application/json", + "Content-Type": "application/json", + }, body, - retryLostResponse: false, }); - // postJson deliberately projects an unparseable non-ok body to null. Treating all null - // error payloads as replayable also covers proxies that mislabel HTML as application/json. - const nonJsonFailure = !result.response.ok && result.payload === null; - if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue; - return result; + if (!response.ok) { + const payload = await readJsonPayload(response); + const nonJsonFailure = payload === null; + if (attempt === 0 && (response.status === 502 || nonJsonFailure)) continue; + return { response, payload, turn: null }; + } + if (response.headers.get("content-type")?.includes("application/x-ndjson")) { + const turn = await readStreamedTurn(response, { + onNarrativeDelta(text) { + emittedNarrative = true; + options.onNarrativeDelta?.(text); + }, + }); + return { response, payload: null, turn }; + } + return { response, payload: await readJsonPayload(response), turn: null }; } catch (error) { - if (attempt === 0 && isRetryableTransportError(error)) continue; + if (attempt === 0 && !emittedNarrative && isRetryableTransportError(error)) continue; throw error; } } @@ -118,11 +183,12 @@ async function postCommandWithOneReplay(body: string) { export async function sendConversationalRectificationCommand( command: ConversationalRectificationCommand, + options: ConversationalRectificationStreamOptions = {}, ): Promise { const request = conversationalRectificationCommandSchema.parse(command); const body = JSON.stringify(request); try { - const { response, payload } = await postCommandWithOneReplay(body); + const { response, payload, turn } = await postCommandWithOneReplay(body, options); if (!response.ok) { const parsed = publicErrorSchema.safeParse(payload); const safeServerMessage = response.status < 500 && parsed.success @@ -134,7 +200,7 @@ export async function sendConversationalRectificationCommand( safeServerMessage, ); } - return conversationalRectificationResponseSchema.parse(payload); + return turn ?? conversationalRectificationResponseSchema.parse(payload); } catch (error) { if (error instanceof ConversationalRectificationRequestError) throw error; throw new ConversationalRectificationRequestError( diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index a0bd8feb..f29b2796 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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]*? { diff --git a/frontend/tests/conversational-rectification-client.test.ts b/frontend/tests/conversational-rectification-client.test.ts index 07238101..6510d37c 100644 --- a/frontend/tests/conversational-rectification-client.test.ts +++ b/frontend/tests/conversational-rectification-client.test.ts @@ -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)?.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); +}); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index ab7105d3..d6523824 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -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, /]+disabled=""[^>]*>保留中的文字<\/textarea>/); assert.match(markup, /aria-label="生时校正对话"/); assert.match(markup, /role="alert"|aria-live="polite"/); diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts index fe2714b8..f6c7c120 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -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(); + 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: "", }); }); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 7da00ddf..f6cf94d4 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -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");