diff --git a/src/session/__tests__/session-store.test.ts b/src/session/__tests__/session-store.test.ts new file mode 100644 index 0000000..cd35e7c --- /dev/null +++ b/src/session/__tests__/session-store.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createSessionStore } from "@/session/session-store"; +import { createEmptySession } from "@/session/types"; + +function withRoot(fn: () => T): T { + let value: T; + createRoot(() => { + value = fn(); + }); + return value!; +} + +describe("createSessionStore", () => { + it("seeds from an empty session", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + expect(store.session.id).toBe("s1"); + expect(store.session.messages).toEqual([]); + }); + }); + + it("appendUserMessage adds a message with a text part and returns its id", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.appendUserMessage("hi", 1500); + expect(store.session.messages).toHaveLength(1); + expect(store.session.messages[0].id).toBe(id); + expect(store.session.messages[0].role).toBe("user"); + expect(store.session.messages[0].parts).toEqual([ + { type: "text", text: "hi" }, + ]); + expect(store.session.updatedAt).toBe(1500); + }); + }); + + it("beginAssistantMessage adds an empty assistant message", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(1500); + expect(store.session.messages).toHaveLength(1); + expect(store.session.messages[0].id).toBe(id); + expect(store.session.messages[0].role).toBe("assistant"); + expect(store.session.messages[0].parts).toEqual([]); + }); + }); + + it("appendAssistantText appends to the last text part when present", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(); + store.appendAssistantText(id, "hello "); + store.appendAssistantText(id, "world"); + expect(store.session.messages[0].parts).toEqual([ + { type: "text", text: "hello world" }, + ]); + }); + }); + + it("appendAssistantText starts a new text part when the previous part isn't text", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(); + store.appendAssistantText(id, "before tool"); + store.addToolCallPart(id, { + type: "tool-call", + toolCallId: "c1", + toolName: "x", + input: {}, + status: "pending", + }); + store.appendAssistantText(id, "after tool"); + const parts = store.session.messages[0].parts; + expect(parts).toHaveLength(3); + expect(parts[2]).toEqual({ type: "text", text: "after tool" }); + }); + }); + + it("replaceLastTextPart overwrites the most recent text part", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(); + store.appendAssistantText(id, "original"); + store.replaceLastTextPart(id, "rewritten with [1](url)"); + expect(store.session.messages[0].parts).toEqual([ + { type: "text", text: "rewritten with [1](url)" }, + ]); + }); + }); + + it("updateToolCallStatus mutates a single tool-call part by id", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(); + store.addToolCallPart(id, { + type: "tool-call", + toolCallId: "c1", + toolName: "x", + input: {}, + status: "pending", + }); + store.addToolCallPart(id, { + type: "tool-call", + toolCallId: "c2", + toolName: "y", + input: {}, + status: "pending", + }); + store.updateToolCallStatus(id, "c1", "success"); + const parts = store.session.messages[0] + .parts as Array<{ type: string; toolCallId?: string; status?: string }>; + expect(parts[0].status).toBe("success"); + expect(parts[1].status).toBe("pending"); + }); + }); + + it("addToolResultPart appends a tool-result part", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + const id = store.beginAssistantMessage(); + store.addToolResultPart(id, { + type: "tool-result", + toolCallId: "c1", + toolName: "x", + output: "ok", + }); + expect(store.session.messages[0].parts).toEqual([ + { + type: "tool-result", + toolCallId: "c1", + toolName: "x", + output: "ok", + }, + ]); + }); + }); + + it("addSources appends sources (and skips when empty)", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + store.addSources([]); + expect(store.session.sources).toEqual([]); + store.addSources([ + { url: "https://a", title: "A" }, + { url: "https://b", title: "B" }, + ]); + store.addSources([{ url: "https://c", title: "C" }]); + expect(store.session.sources.map((s) => s.url)).toEqual([ + "https://a", + "https://b", + "https://c", + ]); + }); + }); + + it("setContextItems / setLastModelId update their fields", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + store.setContextItems({ + notes: ["a.md"], + tags: ["#x"], + sources: [], + }); + store.setLastModelId("openai:gpt-4-turbo"); + expect(store.session.contextItems.notes).toEqual(["a.md"]); + expect(store.session.lastModelId).toBe("openai:gpt-4-turbo"); + }); + }); + + it("replaceSession swaps the underlying state", () => { + withRoot(() => { + const store = createSessionStore(createEmptySession("s1", 1000)); + store.appendUserMessage("first"); + const fresh = createEmptySession("s2", 2000); + fresh.messages.push({ + id: "m1", + role: "assistant", + createdAt: 2000, + parts: [{ type: "text", text: "imported" }], + }); + store.replaceSession(fresh); + expect(store.session.id).toBe("s2"); + expect(store.session.messages).toHaveLength(1); + expect(store.session.messages[0].parts[0]).toEqual({ + type: "text", + text: "imported", + }); + }); + }); +}); diff --git a/src/session/session-store.ts b/src/session/session-store.ts new file mode 100644 index 0000000..b2604b0 --- /dev/null +++ b/src/session/session-store.ts @@ -0,0 +1,182 @@ +import { createStore, produce, type Store } from "solid-js/store"; +import type { ModelId, Source } from "@/types"; +import type { + MessagePart, + SerializedContextItems, + Session, + ToolCallStatus, +} from "@/session/types"; + +type ToolCallPart = Extract; +type ToolResultPart = Extract; + +export interface SessionStore { + /** Reactive view of the session. Use Solid's reactivity to read fields. */ + readonly session: Store; + + /** Adds a user message with a single text part. Returns the new message id. */ + appendUserMessage(text: string, now?: number): string; + + /** Adds an empty assistant message. Returns the new message id. */ + beginAssistantMessage(now?: number): string; + + /** + * Appends `text` to the last text part of `messageId`, or adds a new text + * part if the last part is not text (e.g. came after a tool-result). + */ + appendAssistantText(messageId: string, text: string): void; + + /** + * Replaces the last text part of `messageId` with `text`. Used by the + * source-processor after rewriting numbered references. + */ + replaceLastTextPart(messageId: string, text: string): void; + + addToolCallPart(messageId: string, part: ToolCallPart): void; + updateToolCallStatus( + messageId: string, + toolCallId: string, + status: ToolCallStatus, + ): void; + addToolResultPart(messageId: string, part: ToolResultPart): void; + + addSources(sources: Source[]): void; + setContextItems(items: SerializedContextItems): void; + setLastModelId(id: ModelId | null): void; + + /** Replaces the entire session — typically on file load. */ + replaceSession(next: Session): void; +} + +/** + * Phase 2 centralized session state. Replaces the per-signal state previously + * scattered across `useChatController` (messages, sources, lastSourceLinkNumber) + * with a single Solid store driven by named actions. Solid's fine-grained + * reactivity means downstream readers only re-run on the slice they touched. + */ +export function createSessionStore(initial: Session): SessionStore { + const [session, setSession] = createStore(initial); + + const touch = (now: number = Date.now()) => setSession("updatedAt", now); + const newId = () => crypto.randomUUID(); + + return { + session, + + appendUserMessage(text, now = Date.now()) { + const id = newId(); + setSession( + "messages", + session.messages.length, + { + id, + role: "user", + createdAt: now, + parts: [{ type: "text", text }], + }, + ); + touch(now); + return id; + }, + + beginAssistantMessage(now = Date.now()) { + const id = newId(); + setSession("messages", session.messages.length, { + id, + role: "assistant", + createdAt: now, + parts: [], + }); + touch(now); + return id; + }, + + appendAssistantText(messageId, text) { + setSession( + "messages", + (m) => m.id === messageId, + produce((message) => { + const last = message.parts[message.parts.length - 1]; + if (last && last.type === "text") { + last.text += text; + } else { + message.parts.push({ type: "text", text }); + } + }), + ); + touch(); + }, + + replaceLastTextPart(messageId, text) { + setSession( + "messages", + (m) => m.id === messageId, + produce((message) => { + for (let i = message.parts.length - 1; i >= 0; i--) { + if (message.parts[i].type === "text") { + message.parts[i] = { type: "text", text }; + return; + } + } + message.parts.push({ type: "text", text }); + }), + ); + touch(); + }, + + addToolCallPart(messageId, part) { + setSession( + "messages", + (m) => m.id === messageId, + "parts", + (parts) => [...parts, part], + ); + touch(); + }, + + updateToolCallStatus(messageId, toolCallId, status) { + setSession( + "messages", + (m) => m.id === messageId, + "parts", + (parts) => + parts.map((p) => + p.type === "tool-call" && p.toolCallId === toolCallId + ? { ...p, status } + : p, + ), + ); + touch(); + }, + + addToolResultPart(messageId, part) { + setSession( + "messages", + (m) => m.id === messageId, + "parts", + (parts) => [...parts, part], + ); + touch(); + }, + + addSources(sources) { + if (sources.length === 0) return; + setSession("sources", (cur) => [...cur, ...sources]); + touch(); + }, + + setContextItems(items) { + setSession("contextItems", items); + touch(); + }, + + setLastModelId(id) { + setSession("lastModelId", id); + touch(); + }, + + replaceSession(next) { + setSession(next); + }, + }; +}