server: reconcile ai_sessions persistence to Chunk 9 AISession shape

Reshape the ai_sessions table (migration 003) and mapping/routes to the
full AISession contract: title, kind, notes, created_at, last_message_at;
drop legacy role/model/settings. toAISession now maps the new columns and
routes create/patch/messages handlers use the new shape.
This commit is contained in:
Schwitzz
2026-10-29 15:03:32 -05:00
parent fc8eaa86c3
commit a1b5587fcb
15 changed files with 181 additions and 60 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentController, isRateLimitError } from "./index.js";
import type { ResolvedProvider } from "./index.js";
import type { CompletionUsage, ResolvedProvider } from "./index.js";
/** Build a `text/event-stream` Response from a list of SSE event payloads. */
function sseResponse(events: string[], status = 200): Response {
@@ -78,7 +78,7 @@ describe("AgentController.stream", () => {
])) as unknown as typeof fetch;
const agent = new AgentController(config);
let seen: (typeof config) | null = null;
let seen: CompletionUsage | null = null;
for await (const chunk of agent.stream([{ role: "user", content: "hi" }])) {
if (chunk.type === "done" && chunk.usage) {
seen = chunk.usage;
@@ -132,7 +132,7 @@ describe("AgentController.collect", () => {
describe("thinking-param mapping", () => {
it("maps thinking config onto the request body", async () => {
const calls: Array<RequestInit> = [];
globalThis.fetch = ((url: string, init: RequestInit) => {
globalThis.fetch = ((_url: string, init: RequestInit) => {
calls.push(init);
return Promise.resolve(
sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]),
+8 -4
View File
@@ -143,11 +143,15 @@ export class AgentController {
extraBody: { ...opts.extraBody, ...thinking },
})) {
if (chunk.type === "text") {
cb.onText?.(chunk.text);
buffered.push(chunk);
if (chunk.text !== undefined) {
cb.onText?.(chunk.text);
buffered.push(chunk);
}
} else if (chunk.type === "reasoning") {
cb.onReasoning?.(chunk.reasoning);
buffered.push(chunk);
if (chunk.reasoning !== undefined) {
cb.onReasoning?.(chunk.reasoning);
buffered.push(chunk);
}
} else if (chunk.type === "done") {
if (chunk.usage) {
this.usage = chunk.usage;
+3 -6
View File
@@ -1,17 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AIStore } from "./index.js";
import type { SessionRow } from "./index.js";
import type { AISessionPersistence } from "./index.js";
/** A simple in-memory persistence backend for tests. */
function memoryPersistence(): AISessionPersistence {
const rows = new Map<string, unknown>();
const rows = new Map<string, SessionRow>();
return {
list: async (projectId) =>
Array.from(rows.values())
.filter((r) => r.projectId === projectId)
.map((r) => ({ ...r })),
get: async (id) => (rows.has(id) ? { ...rows.get(id) } : null),
get: async (id) => (rows.has(id) ? ({ ...rows.get(id) } as SessionRow) : null),
save: async (session) => {
rows.set(session.id, { ...session });
},
@@ -62,10 +63,8 @@ describe("AIStore", () => {
it("adds a message to a session", async () => {
const session = await store.createSession();
const updated = await store.addMessage(session.id, {
id: "m1",
role: "user",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
});
expect(updated.messages).toHaveLength(1);
expect(updated.messages[0]?.role).toBe("user");
@@ -74,14 +73,12 @@ describe("AIStore", () => {
it("adds a suggestion and lists it", async () => {
const session = await store.createSession();
const updated = await store.addSuggestion(session.id, {
id: "s1",
sessionId: session.id,
kind: "brainstorm",
title: "Ideas",
text: "some ideas",
summary: "summary",
status: "pending",
createdAt: "2026-01-01T00:00:00.000Z",
});
expect(updated.suggestions).toHaveLength(1);
const list = await store.listSuggestions(session.id);
+3 -2
View File
@@ -24,7 +24,7 @@ import type {
} from "@pubscript/core";
/** A persisted session row. */
interface SessionRow {
export interface SessionRow {
id: string;
projectId: string;
title: string;
@@ -76,7 +76,8 @@ export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersi
return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse());
},
async get(id: string) {
return table.get(id) ?? null;
const row = await table.get(id);
return row ?? null;
},
async save(row: SessionRow) {
await table.put(row);
+1 -1
View File
@@ -97,7 +97,7 @@ describe("NoteBuilder", () => {
const init = calls[0] as RequestInit;
const body = JSON.parse(init.body as string) as Record<string, unknown>;
const messages = body.messages as Array<{ content: string }>;
const messages = body.messages as Array<{ content: string; role?: string }>;
const system = messages.find((m) => m.role === "system")?.content ?? "";
expect(system).toContain("The Stolen Relic");
});
+1 -1
View File
@@ -135,7 +135,7 @@ export class NoteBuilder {
// Extract tags from #word patterns and a leading "tags:" line.
const tagMatches = [...cleaned.matchAll(/#([\p{L}[\]_][\p{L}[\]0-9 ]{1,20})/gu)];
const tags = [...new Set(tagMatches.map((m) => m[1].trim().toLowerCase()))].slice(0, 8);
const tags = [...new Set(tagMatches.map((m) => m[1]!.trim().toLowerCase()))].slice(0, 8);
// Drop the title line and any tags line from the body.
const body = lines
@@ -78,8 +78,6 @@ describe("SuggestionMutation", () => {
const mut = new SuggestionMutation(suggestion({
originalText: "The cat sat",
newText: "The dog barked",
anchorIndex: undefined,
spanLength: undefined,
}));
const result = mut.apply(doc);
expect(result.success).toBe(true);
@@ -91,8 +89,6 @@ describe("SuggestionMutation", () => {
const mut = new SuggestionMutation(suggestion({
originalText: "cat",
newText: "dog",
anchorIndex: undefined,
spanLength: 3,
}));
const result = mut.apply(doc);
expect(result.success).toBe(true);
+1 -1
View File
@@ -87,7 +87,7 @@ describe("SuggestionBuilder", () => {
const init = calls[0] as RequestInit;
const body = JSON.parse(init.body as string) as Record<string, unknown>;
const messages = body.messages as Array<{ content: string }>;
const messages = body.messages as Array<{ content: string; role?: string }>;
const system = messages.find((m) => m.role === "system")?.content ?? "";
expect(system).toContain("The Return");
expect(system).toContain("the capital");
+1 -1
View File
@@ -164,7 +164,7 @@ export class SuggestionBuilder {
const cleaned = text.trim();
const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
const title = firstLine.replace(/^#+\s*/, "").trim().slice(0, 120) || `${kind} suggestion`;
const summary = cleaned.split(/\r?\n\r?\n/)[0].replace(/^#+\s*/, "").trim().slice(0, 300);
const summary = cleaned.split(/\r?\n\r?\n/)[0]!.replace(/^#+\s*/, "").trim().slice(0, 300);
return { title, summary, text: cleaned };
}
}
+6 -3
View File
@@ -200,11 +200,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
async saveAISession(s: AISession): Promise<void> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id,
role: s.role,
model: s.model,
settings: s.settings,
title: s.title,
kind: s.kind,
messages: s.messages,
suggestions: s.suggestions,
notes: s.notes,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
lastMessageAt: s.lastMessageAt,
});
},
+4 -2
View File
@@ -96,7 +96,8 @@ function mockServerFetch(): typeof fetch {
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
if (method === "POST" && m) {
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] };
const now = new Date().toISOString();
const s = { id: body.id ?? id(), projectId: m[1]!, title: body.title ?? "", kind: body.kind ?? "session", messages: body.messages ?? [], suggestions: body.suggestions ?? [], notes: body.notes ?? [], createdAt: body.createdAt ?? now, updatedAt: body.updatedAt ?? now, lastMessageAt: body.lastMessageAt ?? null };
sessions.set(s.id, s);
return json({ session: s });
}
@@ -131,7 +132,8 @@ async function runScenario(adapter: StorageAdapter) {
edges: [],
});
await adapter.saveAISession({
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [],
id: "sess-1", projectId: project.id, title: "Session", kind: "session",
messages: [], suggestions: [], notes: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lastMessageAt: null,
});
const unsub = adapter.onEvent(() => {});
+79 -8
View File
@@ -211,13 +211,23 @@ export interface Note {
linkedTo?: string;
}
export type SuggestionKind =
/** Editor inline-review suggestion kinds. */
export type EditorSuggestionKind =
| "replace"
| "insert"
| "delete"
| "entity-update"
| "draft";
/** AI suggestion kinds produced by the AI provider layer. */
export type SuggestionKind =
| "brainstorm"
| "outline"
| "research"
| "review"
| "expand"
| "summarize";
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
export interface SuggestionTarget {
@@ -234,20 +244,81 @@ export interface Suggestion {
before: string;
after: string;
rationale: string;
kind: SuggestionKind;
kind: EditorSuggestionKind;
status: SuggestionStatus;
}
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author";
/** Kinds of AI-backed sessions. */
export type SessionKind = "session" | "agent";
/** Role of a message within an AI session. */
export type AIMessageRole = "system" | "user" | "assistant";
/** A single message in an AI session. */
export interface AIMessage {
id: string;
createdAt: string;
role: AIMessageRole;
content: string;
}
/** Kind of an AI-generated note. */
export type NoteKind = "character" | "world" | "plot" | "theme" | "general";
/** Lifecycle status of an AI-generated note. */
export type NoteStatus = "draft" | "published";
/** An AI-generated note attached to a session. */
export interface AINote {
id: string;
sessionId: string;
kind: NoteKind;
title?: string;
body: string;
tags?: string[];
status: NoteStatus;
createdAt: string;
updatedAt: string;
}
/** Lifecycle status of an AI-generated suggestion. */
export type AISuggestionStatus =
| "pending"
| "applied"
| "accepted"
| "rejected";
/** An AI-generated suggestion attached to a session. */
export interface AISuggestion {
id: string;
sessionId: string;
createdAt: string;
kind: SuggestionKind;
title: string;
text: string;
summary: string;
originalText?: string;
newText?: string;
anchorIndex?: number;
spanLength?: number;
status: AISuggestionStatus;
appliedAt?: string;
resolvedAt?: string;
resolution?: "accepted" | "rejected";
}
/** An AI-backed session with its messages, suggestions, and notes. */
export interface AISession {
id: string;
projectId: string;
role: AISessionRole;
model: string;
settings: Record<string, unknown>;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
suggestions: Suggestion[];
title: string;
kind: SessionKind;
messages: AIMessage[];
suggestions: AISuggestion[];
notes: AINote[];
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
}
/** A reference to a document that can be saved through the adapter. */