feat(FN-1359): add ChatStore for session and message management
- Add chat system type definitions (ChatSession, ChatMessage, ChatMessageRole) - Add SQLite schema migration for chat_sessions and chat_messages tables - Implement ChatStore with full CRUD operations for sessions and messages - Export ChatStore and types from @fusion/core public API - Add comprehensive test suite for ChatStore with session/message operations - Update schema version expectations in existing tests
This commit is contained in:
520
packages/core/src/__tests__/chat-store.test.ts
Normal file
520
packages/core/src/__tests__/chat-store.test.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { ChatStore } from "../chat-store.js";
|
||||
import { Database } from "../db.js";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-chat-store-test-"));
|
||||
}
|
||||
|
||||
describe("ChatStore", () => {
|
||||
let tmpDir: string;
|
||||
let kbDir: string;
|
||||
let db: Database;
|
||||
let store: ChatStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
kbDir = join(tmpDir, ".fusion");
|
||||
db = new Database(kbDir);
|
||||
db.init();
|
||||
store = new ChatStore(kbDir, db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Helper Functions ─────────────────────────────────────────────
|
||||
|
||||
function createTestSession(
|
||||
store: ChatStore,
|
||||
overrides?: Partial<{
|
||||
agentId: string;
|
||||
title: string | null;
|
||||
projectId: string | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
}>,
|
||||
) {
|
||||
return store.createSession({
|
||||
agentId: overrides?.agentId ?? "agent-001",
|
||||
title: overrides?.title ?? "Test Session",
|
||||
projectId: overrides?.projectId ?? null,
|
||||
modelProvider: overrides?.modelProvider ?? null,
|
||||
modelId: overrides?.modelId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Session CRUD Tests ───────────────────────────────────────────
|
||||
|
||||
describe("Session CRUD", () => {
|
||||
describe("createSession", () => {
|
||||
it("creates a session with correct defaults", () => {
|
||||
const session = store.createSession({ agentId: "agent-001" });
|
||||
|
||||
expect(session.id).toMatch(/^chat-/);
|
||||
expect(session.agentId).toBe("agent-001");
|
||||
expect(session.title).toBeNull();
|
||||
expect(session.status).toBe("active");
|
||||
expect(session.projectId).toBeNull();
|
||||
expect(session.modelProvider).toBeNull();
|
||||
expect(session.modelId).toBeNull();
|
||||
expect(session.createdAt).toBeTruthy();
|
||||
expect(session.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores all provided fields", () => {
|
||||
const session = createTestSession(store, {
|
||||
agentId: "agent-test",
|
||||
title: "My Chat",
|
||||
projectId: "proj-123",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-3",
|
||||
});
|
||||
|
||||
expect(session.agentId).toBe("agent-test");
|
||||
expect(session.title).toBe("My Chat");
|
||||
expect(session.projectId).toBe("proj-123");
|
||||
expect(session.modelProvider).toBe("anthropic");
|
||||
expect(session.modelId).toBe("claude-3");
|
||||
});
|
||||
|
||||
it("generates unique IDs", () => {
|
||||
const s1 = store.createSession({ agentId: "agent-001" });
|
||||
const s2 = store.createSession({ agentId: "agent-001" });
|
||||
|
||||
expect(s1.id).not.toBe(s2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
it("returns session by id", () => {
|
||||
const created = createTestSession(store);
|
||||
const retrieved = store.getSession(created.id);
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(created.id);
|
||||
expect(retrieved!.agentId).toBe(created.agentId);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent session", () => {
|
||||
const result = store.getSession("chat-nonexistent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessions", () => {
|
||||
it("returns all sessions ordered by updatedAt desc", async () => {
|
||||
const s1 = createTestSession(store);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const s2 = createTestSession(store);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const s3 = createTestSession(store);
|
||||
|
||||
const list = store.listSessions();
|
||||
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].id).toBe(s3.id); // Newest first
|
||||
expect(list[1].id).toBe(s2.id);
|
||||
expect(list[2].id).toBe(s1.id);
|
||||
});
|
||||
|
||||
it("filters by projectId", () => {
|
||||
createTestSession(store, { projectId: "proj-A" });
|
||||
createTestSession(store, { projectId: "proj-B" });
|
||||
createTestSession(store, { projectId: "proj-A" });
|
||||
|
||||
const filtered = store.listSessions({ projectId: "proj-A" });
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.every((s) => s.projectId === "proj-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by agentId", () => {
|
||||
createTestSession(store, { agentId: "agent-A" });
|
||||
createTestSession(store, { agentId: "agent-B" });
|
||||
createTestSession(store, { agentId: "agent-A" });
|
||||
|
||||
const filtered = store.listSessions({ agentId: "agent-A" });
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.every((s) => s.agentId === "agent-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
createTestSession(store);
|
||||
const archived = createTestSession(store);
|
||||
store.archiveSession(archived.id);
|
||||
|
||||
const activeSessions = store.listSessions({ status: "active" });
|
||||
const archivedSessions = store.listSessions({ status: "archived" });
|
||||
|
||||
expect(activeSessions).toHaveLength(1);
|
||||
expect(archivedSessions).toHaveLength(1);
|
||||
expect(archivedSessions[0].status).toBe("archived");
|
||||
});
|
||||
|
||||
it("returns empty array when no sessions", () => {
|
||||
const list = store.listSessions();
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("combines multiple filters", () => {
|
||||
createTestSession(store, { agentId: "agent-A", projectId: "proj-A" });
|
||||
createTestSession(store, { agentId: "agent-A", projectId: "proj-B" });
|
||||
createTestSession(store, { agentId: "agent-B", projectId: "proj-A" });
|
||||
|
||||
const filtered = store.listSessions({ agentId: "agent-A", projectId: "proj-A" });
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].agentId).toBe("agent-A");
|
||||
expect(filtered[0].projectId).toBe("proj-A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSession", () => {
|
||||
it("updates title and bumps updatedAt", async () => {
|
||||
const session = createTestSession(store);
|
||||
const originalUpdatedAt = session.updatedAt;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const updated = store.updateSession(session.id, { title: "Updated Title" });
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated!.title).toBe("Updated Title");
|
||||
expect(updated!.id).toBe(session.id);
|
||||
expect(new Date(updated!.updatedAt).getTime()).toBeGreaterThan(
|
||||
new Date(originalUpdatedAt).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates status", () => {
|
||||
const session = createTestSession(store);
|
||||
const updated = store.updateSession(session.id, { status: "archived" });
|
||||
|
||||
expect(updated!.status).toBe("archived");
|
||||
});
|
||||
|
||||
it("updates model fields", () => {
|
||||
const session = createTestSession(store);
|
||||
const updated = store.updateSession(session.id, {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
|
||||
expect(updated!.modelProvider).toBe("openai");
|
||||
expect(updated!.modelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent session", () => {
|
||||
const result = store.updateSession("chat-nonexistent", { title: "Test" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can clear fields by setting to null", () => {
|
||||
const session = createTestSession(store, {
|
||||
title: "Has title",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude",
|
||||
});
|
||||
|
||||
const updated = store.updateSession(session.id, {
|
||||
title: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
});
|
||||
|
||||
expect(updated!.title).toBeNull();
|
||||
expect(updated!.modelProvider).toBeNull();
|
||||
expect(updated!.modelId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveSession", () => {
|
||||
it("sets status to archived", () => {
|
||||
const session = createTestSession(store);
|
||||
const archived = store.archiveSession(session.id);
|
||||
|
||||
expect(archived!.status).toBe("archived");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent session", () => {
|
||||
const result = store.archiveSession("chat-nonexistent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSession", () => {
|
||||
it("removes session from database", () => {
|
||||
const session = createTestSession(store);
|
||||
const deleted = store.deleteSession(session.id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(store.getSession(session.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns false for non-existent session", () => {
|
||||
const result = store.deleteSession("chat-nonexistent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("cascades to delete messages", () => {
|
||||
const session = createTestSession(store);
|
||||
store.addMessage(session.id, { role: "user", content: "Hello" });
|
||||
store.addMessage(session.id, { role: "assistant", content: "Hi there" });
|
||||
|
||||
expect(store.getMessages(session.id)).toHaveLength(2);
|
||||
|
||||
store.deleteSession(session.id);
|
||||
|
||||
expect(store.getMessages(session.id)).toHaveLength(0);
|
||||
expect(store.getSession(session.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Message CRUD Tests ───────────────────────────────────────────
|
||||
|
||||
describe("Message CRUD", () => {
|
||||
describe("addMessage", () => {
|
||||
it("creates message with correct fields", () => {
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, {
|
||||
role: "user",
|
||||
content: "Hello, agent!",
|
||||
});
|
||||
|
||||
expect(message.id).toMatch(/^msg-/);
|
||||
expect(message.sessionId).toBe(session.id);
|
||||
expect(message.role).toBe("user");
|
||||
expect(message.content).toBe("Hello, agent!");
|
||||
expect(message.thinkingOutput).toBeNull();
|
||||
expect(message.metadata).toBeNull();
|
||||
expect(message.createdAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores thinkingOutput when provided", () => {
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, {
|
||||
role: "assistant",
|
||||
content: "I think the best approach is...",
|
||||
thinkingOutput: "Let me reason through this step by step...",
|
||||
});
|
||||
|
||||
expect(message.thinkingOutput).toBe("Let me reason through this step by step...");
|
||||
});
|
||||
|
||||
it("stores metadata when provided", () => {
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, {
|
||||
role: "assistant",
|
||||
content: "Here's my response",
|
||||
metadata: { tokens: 150, finishReason: "stop" },
|
||||
});
|
||||
|
||||
expect(message.metadata).toEqual({ tokens: 150, finishReason: "stop" });
|
||||
});
|
||||
|
||||
it("throws error when session does not exist", () => {
|
||||
expect(() => {
|
||||
store.addMessage("chat-nonexistent", {
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
});
|
||||
}).toThrow("Chat session chat-nonexistent not found");
|
||||
});
|
||||
|
||||
it("updates session's updatedAt timestamp", async () => {
|
||||
const session = createTestSession(store);
|
||||
const originalUpdatedAt = session.updatedAt;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
store.addMessage(session.id, { role: "user", content: "New message" });
|
||||
|
||||
const updated = store.getSession(session.id)!;
|
||||
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(
|
||||
new Date(originalUpdatedAt).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessages", () => {
|
||||
it("returns messages for a session ordered by createdAt ASC", async () => {
|
||||
const session = createTestSession(store);
|
||||
const m1 = store.addMessage(session.id, { role: "user", content: "First" });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const m2 = store.addMessage(session.id, { role: "assistant", content: "Second" });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const m3 = store.addMessage(session.id, { role: "user", content: "Third" });
|
||||
|
||||
const messages = store.getMessages(session.id);
|
||||
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0].id).toBe(m1.id);
|
||||
expect(messages[1].id).toBe(m2.id);
|
||||
expect(messages[2].id).toBe(m3.id);
|
||||
});
|
||||
|
||||
it("respects limit", () => {
|
||||
const session = createTestSession(store);
|
||||
store.addMessage(session.id, { role: "user", content: "1" });
|
||||
store.addMessage(session.id, { role: "user", content: "2" });
|
||||
store.addMessage(session.id, { role: "user", content: "3" });
|
||||
|
||||
const messages = store.getMessages(session.id, { limit: 2 });
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects offset", () => {
|
||||
const session = createTestSession(store);
|
||||
store.addMessage(session.id, { role: "user", content: "1" });
|
||||
store.addMessage(session.id, { role: "user", content: "2" });
|
||||
store.addMessage(session.id, { role: "user", content: "3" });
|
||||
|
||||
const messages = store.getMessages(session.id, { offset: 1 });
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0].content).toBe("2");
|
||||
});
|
||||
|
||||
it("respects before cursor (timestamp)", async () => {
|
||||
const session = createTestSession(store);
|
||||
const m1 = store.addMessage(session.id, { role: "user", content: "1" });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
store.addMessage(session.id, { role: "user", content: "2" });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
store.addMessage(session.id, { role: "user", content: "3" });
|
||||
|
||||
const messages = store.getMessages(session.id, { before: m1.createdAt });
|
||||
|
||||
// Should return messages created before m1 (none in this case)
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("combines limit and offset", () => {
|
||||
const session = createTestSession(store);
|
||||
store.addMessage(session.id, { role: "user", content: "1" });
|
||||
store.addMessage(session.id, { role: "user", content: "2" });
|
||||
store.addMessage(session.id, { role: "user", content: "3" });
|
||||
store.addMessage(session.id, { role: "user", content: "4" });
|
||||
|
||||
const messages = store.getMessages(session.id, { limit: 2, offset: 1 });
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0].content).toBe("2");
|
||||
expect(messages[1].content).toBe("3");
|
||||
});
|
||||
|
||||
it("returns empty array for session with no messages", () => {
|
||||
const session = createTestSession(store);
|
||||
const messages = store.getMessages(session.id);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array for non-existent session", () => {
|
||||
const messages = store.getMessages("chat-nonexistent");
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessage", () => {
|
||||
it("returns message by id", () => {
|
||||
const session = createTestSession(store);
|
||||
const created = store.addMessage(session.id, {
|
||||
role: "user",
|
||||
content: "Test message",
|
||||
});
|
||||
|
||||
const retrieved = store.getMessage(created.id);
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(created.id);
|
||||
expect(retrieved!.content).toBe("Test message");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent message", () => {
|
||||
const result = store.getMessage("msg-nonexistent");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event Emission Tests ─────────────────────────────────────────
|
||||
|
||||
describe("Event emission", () => {
|
||||
it("createSession emits chat:session:created", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:session:created", handler);
|
||||
|
||||
const session = store.createSession({ agentId: "agent-001" });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(session);
|
||||
});
|
||||
|
||||
it("updateSession emits chat:session:updated", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:session:updated", handler);
|
||||
|
||||
const session = createTestSession(store);
|
||||
const updated = store.updateSession(session.id, { title: "Updated" });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
|
||||
it("deleteSession emits chat:session:deleted", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:session:deleted", handler);
|
||||
|
||||
const session = createTestSession(store);
|
||||
store.deleteSession(session.id);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(session.id);
|
||||
});
|
||||
|
||||
it("deleteSession does NOT emit for non-existent session", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:session:deleted", handler);
|
||||
|
||||
store.deleteSession("chat-nonexistent");
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("addMessage emits chat:message:added", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:message:added", handler);
|
||||
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, { role: "user", content: "Hello" });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it("archiveSession emits chat:session:updated", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:session:updated", handler);
|
||||
|
||||
const session = createTestSession(store);
|
||||
store.archiveSession(session.id);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].status).toBe("archived");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
342
packages/core/src/chat-store.ts
Normal file
342
packages/core/src/chat-store.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* ChatStore - Data layer for the agent chat system.
|
||||
*
|
||||
* Manages CRUD operations for chat sessions and messages.
|
||||
* Provides event emission for dashboard reactivity.
|
||||
*
|
||||
* Follows the same patterns as MissionStore:
|
||||
* - EventEmitter for change notifications
|
||||
* - SQLite for structured data storage
|
||||
* - JSON columns for nested data
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database } from "./db.js";
|
||||
import { fromJson, toJsonNullable } from "./db.js";
|
||||
import type {
|
||||
ChatSession,
|
||||
ChatSessionStatus,
|
||||
ChatMessage,
|
||||
ChatMessageRole,
|
||||
ChatMessageCreateInput,
|
||||
ChatSessionCreateInput,
|
||||
ChatSessionUpdateInput,
|
||||
ChatMessagesFilter,
|
||||
} from "./chat-types.js";
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatStoreEvents {
|
||||
/** Emitted when a chat session is created */
|
||||
"chat:session:created": [session: ChatSession];
|
||||
/** Emitted when a chat session is updated */
|
||||
"chat:session:updated": [session: ChatSession];
|
||||
/** Emitted when a chat session is deleted */
|
||||
"chat:session:deleted": [sessionId: string];
|
||||
/** Emitted when a message is added to a session */
|
||||
"chat:message:added": [message: ChatMessage];
|
||||
}
|
||||
|
||||
// ── ChatStore Class ─────────────────────────────────────────────────
|
||||
|
||||
export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
constructor(
|
||||
private kbDir: string,
|
||||
private db: Database,
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
}
|
||||
|
||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a database row to a ChatSession object.
|
||||
*/
|
||||
private rowToSession(row: any): ChatSession {
|
||||
return {
|
||||
id: row.id,
|
||||
agentId: row.agentId,
|
||||
title: row.title ?? null,
|
||||
status: row.status as ChatSessionStatus,
|
||||
projectId: row.projectId ?? null,
|
||||
modelProvider: row.modelProvider ?? null,
|
||||
modelId: row.modelId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a ChatMessage object.
|
||||
*/
|
||||
private rowToMessage(row: any): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.sessionId,
|
||||
role: row.role as ChatMessageRole,
|
||||
content: row.content,
|
||||
thinkingOutput: row.thinkingOutput ?? null,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata) ?? null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session CRUD Operations ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new chat session.
|
||||
*
|
||||
* @param input - Session creation input
|
||||
* @returns The created session
|
||||
*/
|
||||
createSession(input: ChatSessionCreateInput): ChatSession {
|
||||
const now = new Date().toISOString();
|
||||
const id = `chat-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const session: ChatSession = {
|
||||
id,
|
||||
agentId: input.agentId,
|
||||
title: input.title ?? null,
|
||||
status: "active",
|
||||
projectId: input.projectId ?? null,
|
||||
modelProvider: input.modelProvider ?? null,
|
||||
modelId: input.modelId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
session.id,
|
||||
session.agentId,
|
||||
session.title,
|
||||
session.status,
|
||||
session.projectId,
|
||||
session.modelProvider,
|
||||
session.modelId,
|
||||
session.createdAt,
|
||||
session.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("chat:session:created", session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chat session by ID.
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @returns The session, or undefined if not found
|
||||
*/
|
||||
getSession(id: string): ChatSession | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM chat_sessions WHERE id = ?").get(id);
|
||||
if (!row) return undefined;
|
||||
return this.rowToSession(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List chat sessions with optional filtering.
|
||||
*
|
||||
* @param options - Optional filter options
|
||||
* @returns Array of sessions ordered by updatedAt DESC
|
||||
*/
|
||||
listSessions(options?: {
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
status?: ChatSessionStatus;
|
||||
}): ChatSession[] {
|
||||
const whereClauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
|
||||
if (options?.projectId) {
|
||||
whereClauses.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options?.agentId) {
|
||||
whereClauses.push("agentId = ?");
|
||||
params.push(options.agentId);
|
||||
}
|
||||
if (options?.status) {
|
||||
whereClauses.push("status = ?");
|
||||
params.push(options.status);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM chat_sessions ${whereSql} ORDER BY updatedAt DESC
|
||||
`).all(...params);
|
||||
|
||||
return (rows as any[]).map((row) => this.rowToSession(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a chat session.
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @param input - Partial session updates
|
||||
* @returns The updated session, or undefined if not found
|
||||
*/
|
||||
updateSession(id: string, input: ChatSessionUpdateInput): ChatSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const setClauses: string[] = ["updatedAt = ?"];
|
||||
const params: (string | null)[] = [now];
|
||||
|
||||
if (input.title !== undefined) {
|
||||
setClauses.push("title = ?");
|
||||
params.push(input.title);
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
setClauses.push("status = ?");
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.modelProvider !== undefined) {
|
||||
setClauses.push("modelProvider = ?");
|
||||
params.push(input.modelProvider);
|
||||
}
|
||||
if (input.modelId !== undefined) {
|
||||
setClauses.push("modelId = ?");
|
||||
params.push(input.modelId);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE chat_sessions SET ${setClauses.join(", ")} WHERE id = ?
|
||||
`).run(...params);
|
||||
|
||||
const updated = this.getSession(id)!;
|
||||
this.db.bumpLastModified();
|
||||
this.emit("chat:session:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a chat session.
|
||||
* Convenience method that sets status to "archived".
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @returns The archived session, or undefined if not found
|
||||
*/
|
||||
archiveSession(id: string): ChatSession | undefined {
|
||||
return this.updateSession(id, { status: "archived" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat session and all its messages.
|
||||
* Messages are cascade-deleted via foreign key constraint.
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @returns true if deleted, false if not found
|
||||
*/
|
||||
deleteSession(id: string): boolean {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return false;
|
||||
|
||||
this.db.prepare("DELETE FROM chat_sessions WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("chat:session:deleted", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Message CRUD Operations ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a message to a chat session.
|
||||
*
|
||||
* @param sessionId - Parent session ID
|
||||
* @param input - Message content and metadata
|
||||
* @returns The created message
|
||||
* @throws Error if session does not exist
|
||||
*/
|
||||
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Chat session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = `msg-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const message: ChatMessage = {
|
||||
id,
|
||||
sessionId,
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
thinkingOutput: input.thinkingOutput ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
message.id,
|
||||
message.sessionId,
|
||||
message.role,
|
||||
message.content,
|
||||
message.thinkingOutput,
|
||||
toJsonNullable(message.metadata),
|
||||
message.createdAt,
|
||||
);
|
||||
|
||||
// Update session's updatedAt timestamp
|
||||
this.db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("chat:message:added", message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a chat session with optional filtering.
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @param filter - Optional filter (limit, offset, before cursor)
|
||||
* @returns Array of messages ordered by createdAt ASC
|
||||
*/
|
||||
getMessages(sessionId: string, filter?: ChatMessagesFilter): ChatMessage[] {
|
||||
const whereClauses: string[] = ["sessionId = ?"];
|
||||
const params: (string | number)[] = [sessionId];
|
||||
|
||||
// Cursor-based pagination: only return messages created before the cursor
|
||||
if (filter?.before) {
|
||||
whereClauses.push("createdAt < ?");
|
||||
params.push(filter.before);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.join(" AND ");
|
||||
const limit = filter?.limit ?? 100;
|
||||
const offset = filter?.offset ?? 0;
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM chat_messages
|
||||
WHERE ${whereSql}
|
||||
ORDER BY createdAt ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
return (rows as any[]).map((row) => this.rowToMessage(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a message by ID.
|
||||
*
|
||||
* @param id - Message ID
|
||||
* @returns The message, or undefined if not found
|
||||
*/
|
||||
getMessage(id: string): ChatMessage | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM chat_messages WHERE id = ?").get(id);
|
||||
if (!row) return undefined;
|
||||
return this.rowToMessage(row);
|
||||
}
|
||||
}
|
||||
125
packages/core/src/chat-types.ts
Normal file
125
packages/core/src/chat-types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Chat System type definitions.
|
||||
*
|
||||
* Defines the data model for agent chat sessions and messages,
|
||||
* following the same patterns as MissionStore types.
|
||||
*/
|
||||
|
||||
// ── Enums / String Literals ─────────────────────────────────────────────
|
||||
|
||||
/** Status of a chat session */
|
||||
export type ChatSessionStatus = "active" | "archived";
|
||||
|
||||
/** Role of a message sender in a chat */
|
||||
export type ChatMessageRole = "user" | "assistant" | "system";
|
||||
|
||||
// ── Core Types ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A chat session between a user and an agent.
|
||||
* Contains metadata about the conversation and references to the model used.
|
||||
*/
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
/** ID of the agent participating in this session */
|
||||
agentId: string;
|
||||
/** Human-readable title for the session (optional, can be auto-generated) */
|
||||
title: string | null;
|
||||
/** Current status of the session */
|
||||
status: ChatSessionStatus;
|
||||
/** Project ID this session belongs to (optional, for multi-project context) */
|
||||
projectId: string | null;
|
||||
/** AI model provider for this session (optional, overrides defaults) */
|
||||
modelProvider: string | null;
|
||||
/** AI model ID for this session (optional, overrides defaults) */
|
||||
modelId: string | null;
|
||||
/** When the session was created */
|
||||
createdAt: string;
|
||||
/** When the session was last updated */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight view of a chat session for list views.
|
||||
* Currently identical to ChatSession but exists for future extensibility.
|
||||
*/
|
||||
export type ChatSessionSummary = ChatSession;
|
||||
|
||||
/**
|
||||
* A single message within a chat session.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
/** Parent session ID */
|
||||
sessionId: string;
|
||||
/** Role of the message sender */
|
||||
role: ChatMessageRole;
|
||||
/** Message content (text) */
|
||||
content: string;
|
||||
/** Optional thinking/reasoning output (for models that support it) */
|
||||
thinkingOutput: string | null;
|
||||
/** Additional metadata about the message (model, tokens, finish reason, etc.) */
|
||||
metadata: Record<string, unknown> | null;
|
||||
/** When the message was created */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Input Types ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for creating a chat message.
|
||||
*/
|
||||
export interface ChatMessageCreateInput {
|
||||
role: ChatMessageRole;
|
||||
content: string;
|
||||
/** Optional thinking output from the model */
|
||||
thinkingOutput?: string | null;
|
||||
/** Optional metadata (e.g., { tokens: 150, finishReason: "stop" }) */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a chat session.
|
||||
*/
|
||||
export interface ChatSessionCreateInput {
|
||||
agentId: string;
|
||||
/** Optional session title */
|
||||
title?: string | null;
|
||||
/** Optional project ID for multi-project context */
|
||||
projectId?: string | null;
|
||||
/** Optional model provider override */
|
||||
modelProvider?: string | null;
|
||||
/** Optional model ID override */
|
||||
modelId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a chat session.
|
||||
* All fields are optional; only provided fields are updated.
|
||||
*/
|
||||
export interface ChatSessionUpdateInput {
|
||||
/** New session title */
|
||||
title?: string | null;
|
||||
/** New session status */
|
||||
status?: ChatSessionStatus;
|
||||
/** Model provider override */
|
||||
modelProvider?: string | null;
|
||||
/** Model ID override */
|
||||
modelId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for retrieving messages.
|
||||
* Supports cursor-based pagination via `before` timestamp.
|
||||
*/
|
||||
export interface ChatMessagesFilter {
|
||||
/** Maximum number of messages to return */
|
||||
limit?: number;
|
||||
/** Number of messages to skip (offset pagination) */
|
||||
offset?: number;
|
||||
/**
|
||||
* Cursor for pagination: only return messages created before this timestamp.
|
||||
* Used for loading older messages in a conversation.
|
||||
*/
|
||||
before?: string;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -129,7 +129,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -735,8 +735,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
// Verify version bumped to 22 (includes v1→v2 through v21→v22)
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -761,11 +761,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -781,7 +781,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -805,7 +805,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -908,8 +908,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migrations v2→v3→v4
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
// Verify version bumped to 22
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1275,7 +1275,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getSchemaVersion()).toBe(22);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 21;
|
||||
const SCHEMA_VERSION = 22;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -812,6 +812,44 @@ export class Database {
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Chat sessions and messages tables for agent chat system
|
||||
if (version < 22) {
|
||||
this.applyMigration(22, () => {
|
||||
// Chat sessions table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agentId TEXT NOT NULL,
|
||||
title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
projectId TEXT,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatSessionsAgentId ON chat_sessions(agentId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatSessionsProjectId ON chat_sessions(projectId)`);
|
||||
|
||||
// Chat messages table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
sessionId TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
thinkingOutput TEXT,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (sessionId) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatMessagesSessionId ON chat_messages(sessionId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatMessagesCreatedAt ON chat_messages(createdAt)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -277,3 +277,19 @@ export type {
|
||||
ExportOptions,
|
||||
ExportResult,
|
||||
} from "./agent-companies-exporter.js";
|
||||
|
||||
// ── Chat System ───────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
ChatSessionStatus,
|
||||
ChatMessageRole,
|
||||
ChatSession,
|
||||
ChatSessionSummary,
|
||||
ChatMessage,
|
||||
ChatMessageCreateInput,
|
||||
ChatSessionCreateInput,
|
||||
ChatSessionUpdateInput,
|
||||
ChatMessagesFilter,
|
||||
} from "./chat-types.js";
|
||||
export { ChatStore } from "./chat-store.js";
|
||||
export type { ChatStoreEvents } from "./chat-store.js";
|
||||
|
||||
Reference in New Issue
Block a user