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:
gsxdsm
2026-04-09 10:58:21 -07:00
parent 88396b2d05
commit 81a98c3acf
7 changed files with 1054 additions and 13 deletions

View 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");
});
});
});

View File

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

View 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);
}
}

View 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;
}

View File

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

View File

@@ -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)`);
});
}
}
/**

View File

@@ -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";