fix: stream validated rectification replies
This commit is contained in:
+17
-1
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:本次流式修复提交
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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;
|
||||
|
||||
@@ -822,6 +822,7 @@ export default function Home() {
|
||||
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
|
||||
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
|
||||
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationResponse | null>(null);
|
||||
const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState("");
|
||||
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(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() {
|
||||
) : (
|
||||
<ConversationalBirthTimeRectification
|
||||
initialTurn={visibleRectificationTurn}
|
||||
openingAssistantText={rectificationOpeningAssistantText}
|
||||
pendingConsultationQuestion={rectificationPendingQuestion}
|
||||
continuationPending={rectificationContinuationPending}
|
||||
onPendingChange={setRectificationMutationPending}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
|
||||
type SurfaceProps = Readonly<{
|
||||
controller: ConversationalRectificationController;
|
||||
openingAssistantText?: string;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onContinueOriginalQuestion?: (question: string) => 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 (
|
||||
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
<p className="conversational-empty-state" aria-live="polite" role="status">正在建立校正记录…</p>
|
||||
{openingAssistantText
|
||||
? <ChatMessageRow message={{
|
||||
role: "assistant",
|
||||
text: openingAssistantText,
|
||||
renderKey: "rectification-opening-assistant",
|
||||
state: "streaming",
|
||||
}} />
|
||||
: <p className="conversational-empty-state" aria-live="polite" role="status">正在建立校正记录…</p>}
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</section>
|
||||
);
|
||||
@@ -159,7 +168,14 @@ export function ConversationalRectificationSurface({
|
||||
)}
|
||||
</details>
|
||||
{controller.pending && canAnswer && (
|
||||
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
controller.streamingAssistantText
|
||||
? <ChatMessageRow message={{
|
||||
role: "assistant",
|
||||
text: controller.streamingAssistantText,
|
||||
renderKey: `assistant-${turn.turnVersion + 1}`,
|
||||
state: "streaming",
|
||||
}} />
|
||||
: <ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
)}
|
||||
{turn.status === "paused" && <ChatMessageRow message={{ role: "assistant", text: "校正已暂停,输入与现有证据都已保留。", renderKey: "rectification-paused", state: "settled" }} />}
|
||||
{turn.status === "abandoned" && <ChatMessageRow message={{ role: "assistant", text: "本次校正已放弃,候选时间没有应用。", renderKey: "rectification-abandoned", state: "settled" }} />}
|
||||
@@ -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 (
|
||||
<ConversationalRectificationSurface
|
||||
controller={controller}
|
||||
openingAssistantText={props.openingAssistantText}
|
||||
pendingConsultationQuestion={props.pendingConsultationQuestion}
|
||||
continuationPending={props.continuationPending}
|
||||
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createConversationalRectificationActionRegistry,
|
||||
sendConversationalRectificationCommand,
|
||||
type ConversationalRectificationActionIdentity,
|
||||
type ConversationalRectificationStreamOptions,
|
||||
} from "../lib/conversational-rectification/client.ts";
|
||||
import type {
|
||||
ConversationalRectificationCommand,
|
||||
@@ -55,6 +56,7 @@ export type ConversationalRectificationControllerSnapshot = Readonly<{
|
||||
selectedDomain: EvidenceDomain | null;
|
||||
correctionTarget: EvidenceRecapEntry | null;
|
||||
pending: boolean;
|
||||
streamingAssistantText?: string;
|
||||
error: string;
|
||||
}>;
|
||||
|
||||
@@ -78,7 +80,10 @@ export type ConversationalRectificationController = ConversationalRectificationC
|
||||
|
||||
type ControllerInput = Readonly<{
|
||||
initialTurn?: ConversationalRectificationResponse | null;
|
||||
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationResponse>;
|
||||
send?: (
|
||||
command: ConversationalRectificationCommand,
|
||||
options?: ConversationalRectificationStreamOptions,
|
||||
) => Promise<ConversationalRectificationResponse>;
|
||||
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
|
||||
|
||||
@@ -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<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function readStreamedTurn(
|
||||
response: Response,
|
||||
options: ConversationalRectificationStreamOptions,
|
||||
): Promise<ConversationalRectificationResponse> {
|
||||
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<ConversationalRectificationResponse> {
|
||||
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(
|
||||
|
||||
@@ -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