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 <noreply@anthropic.com>
This commit is contained in:
Mike Thicke 2026-05-24 22:37:24 -04:00
parent 3c728718a6
commit dc44c68b59
3 changed files with 99 additions and 6 deletions

View file

@ -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();
});
});

View file

@ -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,
};
}

View file

@ -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 {