PR #182 review (post-fix pass) surfaced two latent correctness risks in the new MessageItem memo: the per-message signature tracks only [type, text length, state, error/output presence] + metadata, so a part kind whose VISIBLE content can change WITHOUT changing those fields would silently freeze a stale row. Neither is reachable with the current toolset (tool output is set once; streaming is append-only with a fixed id), so the correct fix is to harden the documented invariant rather than hash output content on every delta (getPage returns full page content — hashing it per-delta would tax the hot path this PR optimizes). Add a WARNING in messageSignature naming the two future triggers (a tool that streams `preliminary` output; a client-side regenerate/edit that mutates a finalized row in place) and the required action (extend the signature). No behavior change (comment only). vitest src/features/ai-chat 189/189 pass, tsc clean for the touched files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
45 lines
2.0 KiB
TypeScript
45 lines
2.0 KiB
TypeScript
import type { UIMessage } from "@ai-sdk/react";
|
|
|
|
/** Cheap content signature for one message: changes iff something VISIBLE in the
|
|
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
|
|
* appended, a tool/text part flips state once), so a per-part [type, text
|
|
* length, state, error/output presence] tuple + the persisted metadata
|
|
* (error/finishReason) is a sufficient change signal without comparing full
|
|
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
|
|
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
|
|
* text length, state, error/output presence] (e.g. a tool that streams
|
|
* `preliminary` output, or a client-side regenerate that edits a finalized
|
|
* row in place), extend this signature or the memo will freeze a stale row. */
|
|
export function messageSignature(message: UIMessage): string {
|
|
const parts = message.parts
|
|
.map((p) => {
|
|
const any = p as {
|
|
type: string;
|
|
text?: string;
|
|
state?: string;
|
|
errorText?: string;
|
|
output?: unknown;
|
|
};
|
|
return [
|
|
any.type,
|
|
any.text?.length ?? 0,
|
|
any.state ?? "",
|
|
any.errorText ? 1 : 0,
|
|
any.output !== undefined ? 1 : 0,
|
|
].join(":");
|
|
})
|
|
.join("|");
|
|
const meta = message.metadata as
|
|
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
|
|
| undefined;
|
|
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
|
|
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
|
|
// state are already frozen. Without it in the signature the row's signature would be
|
|
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
|
|
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
|
|
// to the exact figure.
|
|
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
|
|
meta?.finishReason ?? ""
|
|
}#${meta?.usage?.reasoningTokens ?? ""}`;
|
|
}
|