Skip to content

Commit d87ca1d

Browse files
committed
perf(webapp): restore the stripped-message cache the UI rewrite dropped
The transcript perf fix had two halves. The winners half kept its identity because a test pinned it; the strip cache had none, so rewriting the component into chat primitives quietly brought back the rebuild-every-render version, and every tool-calling turn re-rendered on each streamed token again. Restore the cache and pin it the same way. orderTranscript is not implicated: it returns a fresh array but the same message objects, which is what the turns memoize on.
1 parent 43a9199 commit d87ca1d

2 files changed

Lines changed: 55 additions & 4 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@ export type DashboardAgentMessagesProps = {
3838
pagePaths?: Record<string, string>;
3939
};
4040

41-
// Returns the same reference when there are no `step-start` parts, so memoization holds.
42-
function stripStepParts(message: UIMessage): UIMessage {
41+
// Cached so a stripped message keeps its identity across renders and memoization holds:
42+
// rebuilding it re-renders every tool-calling turn on each streamed token.
43+
const strippedMessages = new WeakMap<UIMessage, UIMessage>();
44+
45+
export function stripStepParts(message: UIMessage): UIMessage {
4346
if (!message.parts?.some((p) => p.type === "step-start")) return message;
44-
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
47+
const cached = strippedMessages.get(message);
48+
if (cached) return cached;
49+
const stripped = { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
50+
strippedMessages.set(message, stripped);
51+
return stripped;
4552
}
4653

4754
function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | null {

apps/webapp/app/components/dashboard-agent/investigation-winners.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { readFileSync } from "node:fs";
22
import type { UIMessage } from "@ai-sdk/react";
33
import { describe, expect, it } from "vitest";
4-
import { blocksFor, winningInvestigationOccurrences } from "./DashboardAgentMessages";
4+
import {
5+
blocksFor,
6+
stripStepParts,
7+
winningInvestigationOccurrences,
8+
} from "./DashboardAgentMessages";
59
import { reuseWinners, sameOccurrences } from "./investigation-winners";
610

711
const source = readFileSync(new URL("./DashboardAgentMessages.tsx", import.meta.url), "utf8");
@@ -50,6 +54,46 @@ describe("investigation winners identity", () => {
5054
});
5155
});
5256

57+
/**
58+
* The turns are memoized on the message object, so a stripped message rebuilt on every
59+
* render defeats the memo for every tool-calling turn at once.
60+
*/
61+
describe("stripped message identity", () => {
62+
function withStepStart(): UIMessage {
63+
return {
64+
id: "m1",
65+
role: "assistant",
66+
parts: [{ type: "step-start" }, { type: "text", text: "hello" }],
67+
} as unknown as UIMessage;
68+
}
69+
70+
it("returns the very same reference when there is nothing to strip", () => {
71+
const plain = { id: "m1", role: "assistant", parts: [{ type: "text", text: "hi" }] };
72+
const message = plain as unknown as UIMessage;
73+
74+
expect(stripStepParts(message)).toBe(message);
75+
});
76+
77+
it("returns the same stripped reference on every later call", () => {
78+
const message = withStepStart();
79+
const first = stripStepParts(message);
80+
81+
expect(first).not.toBe(message);
82+
expect(first.parts).toHaveLength(1);
83+
for (let token = 0; token < 20; token++) {
84+
expect(stripStepParts(message)).toBe(first);
85+
}
86+
});
87+
88+
it("strips a different message to its own reference", () => {
89+
const a = withStepStart();
90+
const b = withStepStart();
91+
92+
expect(stripStepParts(a)).not.toBe(stripStepParts(b));
93+
expect(stripStepParts(a)).toBe(stripStepParts(a));
94+
});
95+
});
96+
5397
/**
5498
* The winner pass runs once per streamed token over the whole transcript, so it must
5599
* not touch report payloads. `output` is a counting getter because a report parse is

0 commit comments

Comments
 (0)