diff --git a/src/lib/conversationApi.test.ts b/src/lib/conversationApi.test.ts index f706439..13850d1 100644 --- a/src/lib/conversationApi.test.ts +++ b/src/lib/conversationApi.test.ts @@ -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((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); + }); +}); diff --git a/src/lib/conversationApi.ts b/src/lib/conversationApi.ts index 311b011..ad564ae 100644 --- a/src/lib/conversationApi.ts +++ b/src/lib/conversationApi.ts @@ -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>(); + +export function getConversation( threadId: string ): Promise { - return readJson( - 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(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( diff --git a/src/lib/server/conversationResponse.test.ts b/src/lib/server/conversationResponse.test.ts index 9ac3879..e6d33bc 100644 --- a/src/lib/server/conversationResponse.test.ts +++ b/src/lib/server/conversationResponse.test.ts @@ -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", diff --git a/src/lib/server/conversationResponse.ts b/src/lib/server/conversationResponse.ts index 02d7da8..8ba1062 100644 --- a/src/lib/server/conversationResponse.ts +++ b/src/lib/server/conversationResponse.ts @@ -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 {