BUG-1045 (recurrence of BUG-585 via BUG-969): the snapshot merge now drops the attached stem from the streamed "ack + stem" text with the same stripQuestionSentences the GET route uses; server text unchanged. BUG-1046: a failed choice submit (409 / network) or typed send withdraws the local answered mark, remounts the card, re-reads the Case and shows "这次没提交上,请再点一次。"; the persisted question hangs on the latest settled assistant message with other copies of the same focus removed (standalone block only when nothing can carry it); willContinue and the send() settle merge unseen assistant turns (same gap as BUG-685). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
127 lines
5.0 KiB
TypeScript
127 lines
5.0 KiB
TypeScript
import { stripQuestionSentences } from "./rectification-agentic/v9/collect-prompt.ts";
|
|
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
|
|
|
|
export type SnapshotTurnMessage = {
|
|
role: "assistant" | "user";
|
|
turnId?: string;
|
|
text: string;
|
|
renderKey: string;
|
|
question?: TurnQuestion;
|
|
candidateOffer?: { resultId: string };
|
|
};
|
|
|
|
export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
|
current: T[],
|
|
turns: readonly unknown[],
|
|
): T[] {
|
|
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
|
|
for (const item of turns) {
|
|
if (!item || typeof item !== "object") continue;
|
|
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
|
|
if (typeof turn.id !== "string") continue;
|
|
byId.set(turn.id, {
|
|
question: parseTurnQuestion(turn.question),
|
|
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
|
|
});
|
|
}
|
|
return current.map((message) => {
|
|
if (!message.turnId || !byId.has(message.turnId)) return message;
|
|
const next = byId.get(message.turnId);
|
|
const question = next?.question ?? undefined;
|
|
// BUG-1045: a deterministic reply streams 「ack + next stem」 so the stem is
|
|
// readable even if the card never attaches (BUG-969 ③). Once the card is
|
|
// attached here the bubble drops the same sentence — the same
|
|
// `stripQuestionSentences` the GET route's `attachQuestionsToTurns` runs,
|
|
// so the live transcript and a refresh read the same. Idempotent.
|
|
const text = question && message.text
|
|
? stripQuestionSentences(message.text, question.prompt)
|
|
: message.text;
|
|
return {
|
|
...message,
|
|
text,
|
|
question: question ?? undefined,
|
|
candidateOffer: persistedOfferFromTurn(
|
|
next?.offerResultId,
|
|
message.candidateOffer,
|
|
true,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Assistant turns the server wrote that the transcript does not hold yet.
|
|
* `beforeRenderKey` keeps a live row (a tap's follow-up run) last, so a turn
|
|
* the server wrote while applying the tap reads above it (BUG-1046 / D4).
|
|
*/
|
|
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
|
current: T[],
|
|
extras: readonly T[],
|
|
options?: Readonly<{ beforeRenderKey?: string }>,
|
|
): T[] {
|
|
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
|
const incoming = extras.filter((message) => (
|
|
message.role === "assistant"
|
|
&& message.turnId
|
|
&& !known.has(message.turnId)
|
|
));
|
|
if (!incoming.length) return current;
|
|
const at = options?.beforeRenderKey
|
|
? current.findIndex((message) => message.renderKey === options.beforeRenderKey)
|
|
: -1;
|
|
if (at < 0) return [...current, ...incoming];
|
|
return [...current.slice(0, at), ...incoming, ...current.slice(at)];
|
|
}
|
|
|
|
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
|
current: T[],
|
|
turns: readonly unknown[],
|
|
extrasFromTurns: (turns: readonly unknown[]) => T[],
|
|
): T[] {
|
|
return mergeTurnQuestions(
|
|
appendUnseenAssistantTurns(current, extrasFromTurns(turns)),
|
|
turns,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* BUG-1046 / D3: where the Case's persisted question is drawn when no settled
|
|
* message shows it live. It hangs on the latest settled assistant message
|
|
* (BUG-678's intent) and any other copy of the same `focus_id` is taken off, so
|
|
* one focus is drawn once. `standalone` is true only when there is nothing to
|
|
* hang it on: no assistant message at all, or the latest one already carries a
|
|
* different question (then the standalone block is the only place that does
|
|
* not reorder the transcript, and it cannot duplicate this focus).
|
|
*/
|
|
export function placePersistedQuestion<T extends SnapshotTurnMessage>(
|
|
current: T[],
|
|
question: TurnQuestion,
|
|
isSettledAssistant: (message: T) => boolean,
|
|
): Readonly<{ messages: T[]; standalone: boolean }> {
|
|
let target = -1;
|
|
for (let index = current.length - 1; index >= 0; index -= 1) {
|
|
const message = current[index]!;
|
|
if (isSettledAssistant(message) && (message.text || message.question)) {
|
|
target = index;
|
|
break;
|
|
}
|
|
}
|
|
const targetQuestion = target >= 0 ? current[target]!.question : undefined;
|
|
const hang = target >= 0 && (!targetQuestion || targetQuestion.focus_id === question.focus_id);
|
|
let changed = false;
|
|
const messages = current.map((message, index) => {
|
|
if (hang && index === target) {
|
|
// Already live there: leave it. Otherwise (absent, or a local answered
|
|
// mark the server does not hold) the server's active question wins.
|
|
if (message.question && message.question.focus_id === question.focus_id
|
|
&& message.question.status === "active") return message;
|
|
changed = true;
|
|
return { ...message, question };
|
|
}
|
|
if (message.question?.focus_id !== question.focus_id) return message;
|
|
changed = true;
|
|
return { ...message, question: undefined };
|
|
});
|
|
return { messages: changed ? messages : current, standalone: !hang };
|
|
}
|