diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 16:33:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 16:33:45 +0900 |
| commit | 36e950ba2cd2591e86f0dcc898f740481c59d912 (patch) | |
| tree | 7f6e83e991a53fa5ac4f2dcf5d649822ceaf22f2 | |
| parent | 91deffaefb1240d6051c24c9afb7d51beab57e5f (diff) | |
| download | dispatch-36e950ba2cd2591e86f0dcc898f740481c59d912.tar.gz dispatch-36e950ba2cd2591e86f0dcc898f740481c59d912.zip | |
feat(conversation-store): conversation metadata + list + title (Wave 1)
Implement listConversations(), getConversationMeta(), setConversationTitle()
on the ConversationStore. Auto-track createdAt (first write), lastActivityAt
(every append), and title (first user message, truncated 80 chars). A
conv-index key tracks all conversation IDs. 21 new tests (81 total).
| -rw-r--r-- | packages/conversation-store/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 6 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 291 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 207 |
4 files changed, 500 insertions, 6 deletions
diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts index b9f288a..dbd1bf1 100644 --- a/packages/conversation-store/src/index.ts +++ b/packages/conversation-store/src/index.ts @@ -3,4 +3,4 @@ export { extension, manifest } from "./extension.js"; export type { ReconcileReport, ReconcileResult } from "./reconcile.js"; export { reconcile, reconcileWithReport } from "./reconcile.js"; export type { ConversationStore } from "./store.js"; -export { conversationStoreHandle, createConversationStore } from "./store.js"; +export { conversationStoreHandle, createConversationStore, extractTitle } from "./store.js"; diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 5eaed70..9b7c2cc 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -53,3 +53,9 @@ export function cwdKey(conversationId: string): string { export function reasoningEffortKey(conversationId: string): string { return `conv:${conversationId}:reasoning-effort`; } + +export function metaKey(conversationId: string): string { + return `conv:${conversationId}:meta`; +} + +export const CONVERSATION_INDEX_KEY = "conv-index"; diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index b119e2a..1f2b7ba 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -7,7 +7,7 @@ import type { TurnMetrics, } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; -import { createConversationStore } from "./store.js"; +import { createConversationStore, extractTitle } from "./store.js"; interface SpanEvent { readonly kind: "span-open" | "span-close"; @@ -982,3 +982,292 @@ describe("ConversationStore reasoning effort", () => { expect(metricsResult[0]).toEqual(metrics); }); }); + +describe("ConversationStore conversation metadata + list + title", () => { + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("listConversations: returns empty array when no conversations exist", async () => { + const store = createConversationStore(storage); + expect(await store.listConversations()).toEqual([]); + }); + + it("listConversations: returns conversations sorted by lastActivityAt desc", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 2000; + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]); + clock = 3000; + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]); + // Bump conv1 to the most recent activity. + clock = 4000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]); + }); + + it("listConversations: includes id + createdAt + lastActivityAt + title", async () => { + const store = createConversationStore(storage, undefined, () => 12345); + await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + const first = list[0]; + if (first === undefined) throw new Error("expected list entry"); + expect(first).toEqual({ + id: "convX", + createdAt: 12345, + lastActivityAt: 12345, + title: "my title", + }); + }); + + it("getConversationMeta: returns null for unknown conversation", async () => { + const store = createConversationStore(storage); + expect(await store.getConversationMeta("unknown")).toBeNull(); + }); + + it("getConversationMeta: returns metadata for known conversation", async () => { + const store = createConversationStore(storage, undefined, () => 7777); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + expect(await store.getConversationMeta("conv1")).toEqual({ + id: "conv1", + createdAt: 7777, + lastActivityAt: 7777, + title: "hello", + }); + }); + + it("getConversationMeta: returns null on a corrupt meta row", async () => { + const store = createConversationStore(storage); + // Write a meta row with the wrong shape directly to storage. + await storage.set("conv:conv1:meta", "{not json"); + expect(await store.getConversationMeta("conv1")).toBeNull(); + }); + + it("setConversationTitle: updates the title", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); + await store.setConversationTitle("conv1", "custom title"); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + // createdAt + lastActivityAt are preserved (setTitle does not bump them). + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(1000); + }); + + it("setConversationTitle: creates meta if conversation is new", async () => { + const store = createConversationStore(storage, undefined, () => 5000); + await store.setConversationTitle("convNew", "preset title"); + expect(await store.getConversationMeta("convNew")).toEqual({ + id: "convNew", + createdAt: 5000, + lastActivityAt: 5000, + title: "preset title", + }); + // And the new conversation is discoverable in the index. + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["convNew"]); + }); + + it("append: auto-sets title from first user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, + { role: "user", chunks: [{ type: "text", text: "hello world" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world"); + }); + + it("append: truncates long titles to 80 chars", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + const longText = "x".repeat(100); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe(`${longText.slice(0, 80)}…`); + expect(meta?.title.length).toBe(81); + }); + + it("append: sets createdAt on first write, preserves on subsequent", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 5000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(5000); + }); + + it("append: updates lastActivityAt on every write", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000); + clock = 2000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000); + clock = 3000; + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000); + }); + + it('append: title "Untitled" updated when first user message arrives in later append', async () => { + const store = createConversationStore(storage, undefined, () => 1000); + // First append — assistant only, no user message yet → "Untitled". + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled"); + // Second append — the first user message arrives → title is re-derived. + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("what now"); + }); + + it("append: a non-Untitled title is NOT overwritten by a later user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "first question" }] }, + ]); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "second question" }] }, + ]); + // The title stays as the first user message; later user messages do not clobber. + expect((await store.getConversationMeta("conv1"))?.title).toBe("first question"); + }); + + it("append: does not add the same conversation to the index twice", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); + + it("listConversations: skips index entries whose meta row is missing", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + // Manually corrupt the index by adding an id with no meta row. + await storage.set("conv-index", JSON.stringify(["conv1", "ghost"])); + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1"]); + }); + + it("metadata persists across a fresh store instance on the same storage", async () => { + const clock = 1000; + const store1 = createConversationStore(storage, undefined, () => clock); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]); + + const store2 = createConversationStore(storage); + const meta = await store2.getConversationMeta("conv1"); + expect(meta).toEqual({ + id: "conv1", + createdAt: 1000, + lastActivityAt: 1000, + title: "persisted", + }); + const list = await store2.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); +}); + +describe("extractTitle (pure)", () => { + it("extractTitle: returns first user text", () => { + const messages: ChatMessage[] = [ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "greeting" }] }, + { role: "user", chunks: [{ type: "text", text: "my question" }] }, + { role: "assistant", chunks: [{ type: "text", text: "answer" }] }, + ]; + expect(extractTitle(messages)).toBe("my question"); + }); + + it('extractTitle: returns "Untitled" when no user message', () => { + expect(extractTitle([])).toBe("Untitled"); + expect( + extractTitle([ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]), + ).toBe("Untitled"); + // A user message with no text chunk also yields "Untitled". + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + ]), + ).toBe("Untitled"); + }); + + it("extractTitle: truncates to 80 chars", () => { + const exactly80 = "a".repeat(80); + const over80 = "a".repeat(81); + const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe( + exactly80, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe( + `${over80.slice(0, 80)}…`, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe( + `${wayOver.slice(0, 80)}…`, + ); + }); + + it("extractTitle: uses the first text chunk of the first user message", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { type: "text", text: "first chunk" }, + { type: "text", text: "second chunk" }, + ], + }, + ]), + ).toBe("first chunk"); + }); + + it("extractTitle: skips a user message with no text chunk, finds the next", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + { role: "user", chunks: [{ type: "text", text: "real question" }] }, + ]), + ).toBe("real question"); + }); + + it("extractTitle: does not mutate the input", () => { + const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]; + const snapshot = JSON.stringify(messages); + extractTitle(messages); + expect(JSON.stringify(messages)).toBe(snapshot); + }); +}); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 188f780..f3bec4b 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -11,9 +11,11 @@ import type { } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import { + CONVERSATION_INDEX_KEY, chunkKey, chunkPrefix, cwdKey, + metaKey, metricsKey, metricsPrefix, metricsSeqKey, @@ -110,10 +112,102 @@ interface PersistedChunkEntry { readonly chunkIdx: number; } +/** + * The persisted shape of a conversation's metadata (JSON at `metaKey(id)`). + * Maps to `ConversationMeta` (from `@dispatch/wire`) by adding the `id`. + */ +interface ConversationMetaRow { + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; +} + +/** Maximum title length (in characters) before truncation with an ellipsis. */ +const TITLE_MAX = 80; + +/** + * Derive a human-readable title from a batch of messages: the text of the + * first `role: "user"` message's first `type: "text"` chunk, truncated to + * {@link TITLE_MAX} characters with a trailing `"…"` when longer. Returns + * `"Untitled"` when no user text chunk is present. + * + * Pure (input → output); exported so callers can preview a title without + * persisting. + */ +export function extractTitle(messages: readonly ChatMessage[]): string { + for (const msg of messages) { + if (msg.role !== "user") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "text") { + return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; + } + } + } + return "Untitled"; +} + +/** + * Parse a persisted {@link ConversationMetaRow}, returning `null` on any + * parse / shape failure so callers can treat a corrupt row as missing. + */ +function parseMetaRow(raw: string): ConversationMetaRow | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as ConversationMetaRow).createdAt !== "number" || + typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || + typeof (parsed as ConversationMetaRow).title !== "string" + ) { + return null; + } + return parsed as ConversationMetaRow; +} + +function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { + return { + id, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + }; +} + export function createConversationStore( storage: StorageNamespace, logger?: Logger, + now: () => number = Date.now, ): ConversationStore { + /** + * Add `conversationId` to the persisted index (idempotent). The store is + * not highly concurrent — the session-orchestrator serializes turns per + * conversation — so a simple read-modify-write suffices; `listConversations` + * deduplicates on read in case of a race on this update. + */ + async function ensureInIndex(conversationId: string): Promise<void> { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + let ids: string[]; + if (raw === null) { + ids = []; + } else { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = []; + } + ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; + } + if (ids.includes(conversationId)) return; + ids.push(conversationId); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); + } + return { async append(conversationId, messages) { const raw = await storage.get(seqKey(conversationId)); @@ -137,6 +231,43 @@ export function createConversationStore( } await storage.set(seqKey(conversationId), String(seq - 1)); + + // Metadata upsert: track createdAt/lastActivityAt/title and keep the + // conversation discoverable in the index. + const ts = now(); + const metaRaw = await storage.get(metaKey(conversationId)); + if (metaRaw === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + const existing = parseMetaRow(metaRaw); + if (existing === null) { + // Corrupt row — rewrite from scratch using this append. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + const title = + existing.title === "Untitled" || existing.title === "" + ? extractTitle(messages) + : existing.title; + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: ts, + title, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + } + } }, async load(conversationId) { @@ -257,11 +388,79 @@ export function createConversationStore( } }, async listConversations() { - return []; + const raw = await storage.get(CONVERSATION_INDEX_KEY); + if (raw === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + // Deduplicate (in case of a race on the index update) while preserving + // first-seen order. + const seen = new Set<string>(); + const ids: string[] = []; + for (const v of parsed) { + if (typeof v !== "string" || seen.has(v)) continue; + seen.add(v); + ids.push(v); + } + + const metas: ConversationMeta[] = []; + for (const id of ids) { + const metaRaw = await storage.get(metaKey(id)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + metas.push(toMeta(id, row)); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps first-seen (index) order for ties. + return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); }, - async getConversationMeta(_conversationId: string) { - return null; + + async getConversationMeta(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return toMeta(conversationId, row); + }, + + async setConversationTitle(conversationId, title) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Title set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + // Corrupt row — rewrite from scratch with this title. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + // Preserve createdAt + lastActivityAt; update only the title. + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); }, - async setConversationTitle(_conversationId: string, _title: string) {}, }; } |
