diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 00:08:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 00:08:21 +0900 |
| commit | 7ff9f94c41a9870e124a50133cd74b42295ab9ac (patch) | |
| tree | 3a3f09d843dc3263983fa44b384ecc3c1a32e750 /packages/conversation-store/src | |
| parent | 037c136823a900e28864e4dd48e1dbe626e95dfb (diff) | |
| download | dispatch-7ff9f94c41a9870e124a50133cd74b42295ab9ac.tar.gz dispatch-7ff9f94c41a9870e124a50133cd74b42295ab9ac.zip | |
feat: conversation lifecycle status (active/idle/closed) for tab persistence
Implement roadmap item 9: tab persistence across devices.
Wire (0.10.0):
- Add ConversationStatus type (active | idle | closed)
- Add status field to ConversationMeta
Transport-contract (0.14.0):
- Add conversation.statusChanged WS message to WsServerMessage union
- Re-export ConversationStatus
Conversation-store:
- Track status in ConversationMetaRow (default: idle)
- getConversationStatus / setConversationStatus methods
- listConversations accepts { status: ConversationStatus[] } filter
- Old meta rows without status default to idle on read
Session-orchestrator:
- conversationStatusChanged hook descriptor
- Emit on transitions: idle→active (turn start), active→idle (turn settle),
→closed (closeConversation)
- Persist status to store as fire-and-forget side effect
- Declare hook in manifest contributes.hooks
Transport-ws:
- Subscribe to conversationStatusChanged hook
- Broadcast conversation.statusChanged WS message to all clients
Transport-http:
- GET /conversations?status=active,idle filter (parseStatusFilter pure helper)
- POST /conversations/:id/close now sets status to closed
CLI:
- dispatch list defaults to active,idle (excludes closed)
- --status <state> flag to filter by single status
- --all flag to include closed
FE handoff: frontend-conversation-lifecycle-handoff.md
Diffstat (limited to 'packages/conversation-store/src')
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 99 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 75 |
2 files changed, 168 insertions, 6 deletions
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 1f2b7ba..fee7de5 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -7,6 +7,7 @@ import type { TurnMetrics, } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; +import { CONVERSATION_INDEX_KEY, metaKey } from "./keys.js"; import { createConversationStore, extractTitle } from "./store.js"; interface SpanEvent { @@ -1023,6 +1024,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 12345, lastActivityAt: 12345, title: "my title", + status: "idle", }); }); @@ -1039,6 +1041,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 7777, lastActivityAt: 7777, title: "hello", + status: "idle", }); }); @@ -1068,6 +1071,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 5000, lastActivityAt: 5000, title: "preset title", + status: "idle", }); // And the new conversation is discoverable in the index. const list = await store.listConversations(); @@ -1170,14 +1174,105 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 1000, lastActivityAt: 1000, title: "persisted", + status: "idle", }); const list = await store2.listConversations(); expect(list).toHaveLength(1); expect(list[0]?.id).toBe("conv1"); }); -}); -describe("extractTitle (pure)", () => { + describe("ConversationStore conversation status", () => { + it("new conversation defaults to idle", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); + }); + + it("setConversationStatus updates status on existing conversation", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + await store.setConversationStatus("conv1", "active"); + expect(await store.getConversationStatus("conv1")).toBe("active"); + + await store.setConversationStatus("conv1", "idle"); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + + await store.setConversationStatus("conv1", "closed"); + expect(await store.getConversationStatus("conv1")).toBe("closed"); + }); + + it("setConversationStatus creates minimal row for unknown conversation", async () => { + const store = createConversationStore(storage, undefined, () => 2000); + await store.setConversationStatus("convNew", "closed"); + expect(await store.getConversationStatus("convNew")).toBe("closed"); + const meta = await store.getConversationMeta("convNew"); + expect(meta?.status).toBe("closed"); + expect(meta?.title).toBe("Untitled"); + }); + + it("setConversationStatus preserves other metadata", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setConversationTitle("conv1", "custom title"); + + await store.setConversationStatus("conv1", "active"); + + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.status).toBe("active"); + }); + + it("listConversations filters by status", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + + await store.setConversationStatus("conv1", "active"); + await store.setConversationStatus("conv2", "closed"); + + const activeOnly = await store.listConversations({ status: ["active"] }); + expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); + + const idleOnly = await store.listConversations({ status: ["idle"] }); + expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); + + const activeIdle = await store.listConversations({ status: ["active", "idle"] }); + expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); + + const all = await store.listConversations(); + expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); + }); + + it("status persists across a fresh store instance", async () => { + const store1 = createConversationStore(storage, undefined, () => 1000); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store1.setConversationStatus("conv1", "active"); + + const store2 = createConversationStore(storage); + expect(await store2.getConversationStatus("conv1")).toBe("active"); + }); + + it("old meta rows without status default to idle on read", async () => { + // Simulate a pre-status meta row written by an older version. + await storage.set( + metaKey("conv1"), + JSON.stringify({ + createdAt: 1000, + lastActivityAt: 1000, + title: "old", + }), + ); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); + + const store = createConversationStore(storage); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.status).toBe("idle"); + }); + }); it("extractTitle: returns first user text", () => { const messages: ChatMessage[] = [ { role: "system", chunks: [{ type: "text", text: "sys" }] }, diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index f3bec4b..8d78df8 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -2,6 +2,7 @@ import type { ChatMessage, Chunk, ConversationMeta, + ConversationStatus, Logger, ReasoningEffort, Role, @@ -71,11 +72,20 @@ export interface ConversationStore { * recent first). Metadata (createdAt, lastActivityAt, title) is tracked * automatically on append; title defaults to the first user message. */ - readonly listConversations: () => Promise<readonly ConversationMeta[]>; + readonly listConversations: (filter?: { + readonly status?: readonly ConversationStatus[]; + }) => Promise<readonly ConversationMeta[]>; /** Single conversation metadata, or null if unknown. */ readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>; /** Set/update the human-readable title for a conversation. */ readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>; + /** Get the lifecycle status of a conversation, or null if unknown. */ + readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>; + /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ + readonly setConversationStatus: ( + conversationId: string, + status: ConversationStatus, + ) => Promise<void>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -120,6 +130,7 @@ interface ConversationMetaRow { readonly createdAt: number; readonly lastActivityAt: number; readonly title: string; + readonly status: ConversationStatus; } /** Maximum title length (in characters) before truncation with an ellipsis. */ @@ -166,7 +177,10 @@ function parseMetaRow(raw: string): ConversationMetaRow | null { ) { return null; } - return parsed as ConversationMetaRow; + const row = parsed as ConversationMetaRow; + const status: ConversationStatus = + row.status === "active" || row.status === "closed" ? row.status : "idle"; + return { createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, title: row.title, status }; } function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { @@ -175,6 +189,7 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, title: row.title, + status: row.status, }; } @@ -241,6 +256,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title: extractTitle(messages), + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -252,6 +268,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title: extractTitle(messages), + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -264,6 +281,7 @@ export function createConversationStore( createdAt: existing.createdAt, lastActivityAt: ts, title, + status: existing.status, }; await storage.set(metaKey(conversationId), JSON.stringify(row)); } @@ -387,7 +405,7 @@ export function createConversationStore( logger.debug("reasoning-effort set", { conversationId }); } }, - async listConversations() { + async listConversations(filter) { const raw = await storage.get(CONVERSATION_INDEX_KEY); if (raw === null) return []; let parsed: unknown; @@ -407,12 +425,14 @@ export function createConversationStore( ids.push(v); } + const statusFilter = filter?.status; 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; + if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; metas.push(toMeta(id, row)); } // Sort by lastActivityAt descending (most recent first). Stable sort @@ -437,6 +457,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title, + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -449,16 +470,62 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title, + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); return; } - // Preserve createdAt + lastActivityAt; update only the title. + // Preserve createdAt + lastActivityAt + status; update only the title. const row: ConversationMetaRow = { createdAt: existing.createdAt, lastActivityAt: existing.lastActivityAt, title, + status: existing.status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getConversationStatus(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return row.status; + }, + + async setConversationStatus(conversationId, status) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Status set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status, }; await storage.set(metaKey(conversationId), JSON.stringify(row)); }, |
