From dc44c68b59b17e5a53e330e50db34f402ccd4bf6 Mon Sep 17 00:00:00 2001 From: Mike Thicke Date: Sun, 24 May 2026 22:37:24 -0400 Subject: [PATCH] Extract buildChatRequest out of ChatInterface (#21) Pulls the inline ChatRequest assembly into a stateless helper in src/chat/. Snapshots messages on build so post-send mutations by the caller don't leak into the in-flight request. Unit tests cover field passthrough, uuid generation, and the snapshot guarantee. Co-Authored-By: Claude Opus 4.7 --- src/chat/__tests__/request-builder.test.ts | 64 ++++++++++++++++++++++ src/chat/request-builder.ts | 29 ++++++++++ src/components/ChatInterface.tsx | 12 ++-- 3 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 src/chat/__tests__/request-builder.test.ts create mode 100644 src/chat/request-builder.ts diff --git a/src/chat/__tests__/request-builder.test.ts b/src/chat/__tests__/request-builder.test.ts new file mode 100644 index 0000000..6920c13 --- /dev/null +++ b/src/chat/__tests__/request-builder.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { buildChatRequest } from "@/chat/request-builder"; +import type { Model, ModelChatMessage } from "@/types"; + +const model: Model = { + id: "openai:gpt-4-turbo", + provider: "openai", + name: "GPT-4 Turbo", + renaming: false, + toggleWebSearch: true, + streaming: true, +}; + +const messages: ModelChatMessage[] = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, +]; + +describe("buildChatRequest", () => { + it("populates modelId from the supplied model", () => { + const request = buildChatRequest({ model, messages }); + expect(request.modelId).toBe(model.id); + }); + + it("assigns a uuid-shaped requestID", () => { + const request = buildChatRequest({ model, messages }); + expect(request.requestID).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("issues a fresh requestID for each call", () => { + const a = buildChatRequest({ model, messages }); + const b = buildChatRequest({ model, messages }); + expect(a.requestID).not.toBe(b.requestID); + }); + + it("snapshots the messages array so caller mutations don't leak", () => { + const local = [...messages]; + const request = buildChatRequest({ model, messages: local }); + local.push({ role: "user", content: "mutated after build" }); + expect(request.messages).toHaveLength(2); + }); + + it("passes through optional fields", () => { + const request = buildChatRequest({ + model, + messages, + context: [{ title: "note", content: "body" }], + webSearch: true, + systemPrompt: "be terse", + }); + expect(request.context).toEqual([{ title: "note", content: "body" }]); + expect(request.webSearch).toBe(true); + expect(request.systemPrompt).toBe("be terse"); + }); + + it("leaves optional fields undefined when omitted", () => { + const request = buildChatRequest({ model, messages }); + expect(request.context).toBeUndefined(); + expect(request.webSearch).toBeUndefined(); + expect(request.systemPrompt).toBeUndefined(); + }); +}); diff --git a/src/chat/request-builder.ts b/src/chat/request-builder.ts new file mode 100644 index 0000000..b4de825 --- /dev/null +++ b/src/chat/request-builder.ts @@ -0,0 +1,29 @@ +import { + ChatRequest, + ContextItemContent, + Model, + ModelChatMessage, +} from "@/types"; + +export interface BuildChatRequestParams { + model: Model; + messages: ModelChatMessage[]; + context?: ContextItemContent[]; + webSearch?: boolean; + systemPrompt?: string; +} + +/** + * Assembles a {@link ChatRequest} from its inputs. Snapshots the messages array + * so later mutations by the caller don't leak into the in-flight request. + */ +export function buildChatRequest(params: BuildChatRequestParams): ChatRequest { + return { + requestID: crypto.randomUUID(), + modelId: params.model.id, + messages: [...params.messages], + context: params.context, + webSearch: params.webSearch, + systemPrompt: params.systemPrompt, + }; +} diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index 8353f2f..2c5b0ac 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -18,6 +18,7 @@ import { SourceList } from "@/components/SourceList"; import { getContext } from "@/utils/model-context"; import { ensureSourceTitle } from "@/utils/url"; +import { buildChatRequest } from "@/chat/request-builder"; import { loadSystemPrompt } from "@/chat/system-prompt-loader"; import { HandleChatChangeProps } from "@/ChatView"; @@ -140,14 +141,13 @@ export const ChatInterface = ({ ); } - const request: ChatRequest = { - requestID: crypto.randomUUID(), - modelId: requestModel.id, - messages: [...messages()], + const request = buildChatRequest({ + model: requestModel, + messages: messages(), context: parsedContext, webSearch: webSearchEnabled, - systemPrompt: systemPrompt, - }; + systemPrompt, + }); setCurrentRequest(request); try {