perf(webui): strip agent-internal state and dedupe snapshot fetches
Thread state carried ~1.9MB of internal runtime data (_quickjs_snapshot_payload, skills_metadata) that no WebUI code reads, and opening a conversation fired three independent fetches of it. The BFF now strips those keys (1.9MB -> ~15KB) and getConversation shares one in-flight request across concurrent callers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,56 @@ describe("conversation API errors", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConversation request dedupe", () => {
|
||||
it("shares one in-flight fetch across concurrent callers", async () => {
|
||||
let resolveFetch: ((response: Response) => void) | null = null;
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = getConversation("thread-a");
|
||||
const second = getConversation("thread-a");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFetch!(
|
||||
new Response(JSON.stringify({ thread: {}, state: {} }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
await expect(first).resolves.toEqual({ thread: {}, state: {} });
|
||||
await expect(second).resolves.toEqual({ thread: {}, state: {} });
|
||||
|
||||
// Once settled, the next call fetches again (no stale caching).
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ thread: {}, state: {} }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
await getConversation("thread-a");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not dedupe failures into a poisoned entry", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "boom" }), { status: 500 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getConversation("thread-b")).rejects.toMatchObject({
|
||||
status: 500,
|
||||
});
|
||||
await expect(getConversation("thread-b")).rejects.toMatchObject({
|
||||
status: 500,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,14 +72,26 @@ export async function createConversation(): Promise<{ threadId: string }> {
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function getConversation(
|
||||
// Opening a conversation fires several effects that all need the same
|
||||
// snapshot; share one in-flight request instead of fetching 3x.
|
||||
const inflightConversations = new Map<string, Promise<ConversationSnapshot>>();
|
||||
|
||||
export function getConversation(
|
||||
threadId: string
|
||||
): Promise<ConversationSnapshot> {
|
||||
return readJson<ConversationSnapshot>(
|
||||
await fetch(`/api/conversations/${encodeURIComponent(threadId)}`, {
|
||||
cache: "no-store",
|
||||
})
|
||||
);
|
||||
const inflight = inflightConversations.get(threadId);
|
||||
if (inflight) return inflight;
|
||||
const request = fetch(`/api/conversations/${encodeURIComponent(threadId)}`, {
|
||||
cache: "no-store",
|
||||
}).then((response) => readJson<ConversationSnapshot>(response));
|
||||
inflightConversations.set(threadId, request);
|
||||
const cleanup = () => {
|
||||
if (inflightConversations.get(threadId) === request) {
|
||||
inflightConversations.delete(threadId);
|
||||
}
|
||||
};
|
||||
request.then(cleanup, cleanup);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function patchConversation(
|
||||
|
||||
@@ -41,6 +41,20 @@ describe("conversation response isolation", () => {
|
||||
).toEqual({ values: { messages: [{ content: "visible" }] } });
|
||||
});
|
||||
|
||||
it("strips internal agent state that the browser never reads", () => {
|
||||
// These keys dominate the thread-state payload (1.9MB of 1.9MB) and no
|
||||
// WebUI code path references them; forwarding them is pure parse cost.
|
||||
expect(
|
||||
sanitizeConversationData({
|
||||
values: {
|
||||
_quickjs_snapshot_payload: { snapshot: "x".repeat(100) },
|
||||
skills_metadata: [{ name: "skill-a" }],
|
||||
messages: [{ content: "visible" }],
|
||||
},
|
||||
})
|
||||
).toEqual({ values: { messages: [{ content: "visible" }] } });
|
||||
});
|
||||
|
||||
it("requires all durable metadata fields to match the registry", () => {
|
||||
assertThreadMatchesScope(
|
||||
"thread-a",
|
||||
|
||||
@@ -8,6 +8,10 @@ const INTERNAL_STATE_KEYS = new Set([
|
||||
"workspace_dir",
|
||||
"workspace_root",
|
||||
"workspace_backend",
|
||||
// Agent-internal runtime state. Together these are ~1.9MB of a typical
|
||||
// thread-state payload and no WebUI code path reads them.
|
||||
"_quickjs_snapshot_payload",
|
||||
"skills_metadata",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
Reference in New Issue
Block a user