Centralized createStore for session state (#36)

src/session/session-store.ts exposes createSessionStore(initial) returning
{ session, appendUserMessage, beginAssistantMessage, appendAssistantText,
replaceLastTextPart, addToolCallPart, updateToolCallStatus, addToolResultPart,
addSources, setContextItems, setLastModelId, replaceSession }.

Uses Solid's createStore + produce so fine-grained reactivity is preserved
(downstream readers only re-run on the slice they touched). Every mutating
action bumps updatedAt so persistence layers can debounce on the timestamp.

appendAssistantText is the streaming-friendly action: appends to the trailing
text part when present, starts a new text part when the trailing part isn't
text (e.g. came right after a tool-result). replaceLastTextPart supports the
source-processor's rewrite-with-numbered-references step.

The hook + ChatView wire-up that swaps from per-signal state to this store
lands in #37.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Mike Thicke 2026-05-25 11:11:11 -04:00
parent 2f311ea655
commit b2b4981ae5
2 changed files with 372 additions and 0 deletions

View file

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

View file

@ -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<MessagePart, { type: "tool-call" }>;
type ToolResultPart = Extract<MessagePart, { type: "tool-result" }>;
export interface SessionStore {
/** Reactive view of the session. Use Solid's reactivity to read fields. */
readonly session: Store<Session>;
/** 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<Session>(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);
},
};
}