summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 16:33:45 +0900
committerAdam Malczewski <[email protected]>2026-06-21 16:33:45 +0900
commit36e950ba2cd2591e86f0dcc898f740481c59d912 (patch)
tree7f6e83e991a53fa5ac4f2dcf5d649822ceaf22f2
parent91deffaefb1240d6051c24c9afb7d51beab57e5f (diff)
downloaddispatch-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.ts2
-rw-r--r--packages/conversation-store/src/keys.ts6
-rw-r--r--packages/conversation-store/src/store.test.ts291
-rw-r--r--packages/conversation-store/src/store.ts207
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) {},
};
}