mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
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:
parent
2f311ea655
commit
b2b4981ae5
2 changed files with 372 additions and 0 deletions
190
src/session/__tests__/session-store.test.ts
Normal file
190
src/session/__tests__/session-store.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
182
src/session/session-store.ts
Normal file
182
src/session/session-store.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue