diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 16:19:17 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 16:19:17 +0900 |
| commit | 91deffaefb1240d6051c24c9afb7d51beab57e5f (patch) | |
| tree | afbf8f138c1a852638ca73aae12849687478a586 | |
| parent | 6cfe0dc1df99c46407b0784576c3d4b0f1cb7349 (diff) | |
| download | dispatch-91deffaefb1240d6051c24c9afb7d51beab57e5f.tar.gz dispatch-91deffaefb1240d6051c24c9afb7d51beab57e5f.zip | |
feat(cli): Wave 0 — contracts for conversation list, last message, open tab
Additive contract changes for the CLI milestone (roadmap items 2 + 4):
@dispatch/wire 0.8.0 → 0.9.0:
- ConversationMeta { id, createdAt, lastActivityAt, title }
@dispatch/transport-contract 0.12.0 → 0.13.0:
- ConversationListResponse, LastMessageResponse, OpenConversationResponse
- SetTitleRequest, TitleResponse
- WS conversation.open broadcast (additive to WsServerMessage)
ConversationStore interface:
- listConversations(), getConversationMeta(), setConversationTitle()
- Stub implementations in real store + 11 test fakes (Wave 1 fills in)
Transport-http manifest: new routes declared
(GET /conversations, GET /conversations/:id/last,
POST /conversations/:id/open, PUT /conversations/:id/title)
| -rw-r--r-- | packages/conversation-store/src/store.ts | 18 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/conversation.ts | 1 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/index.ts | 1 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 28 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 7 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 65 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 35 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 4 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 7 | ||||
| -rw-r--r-- | packages/wire/package.json | 2 | ||||
| -rw-r--r-- | packages/wire/src/index.ts | 15 |
12 files changed, 182 insertions, 3 deletions
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index ef5654b..188f780 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,6 +1,7 @@ import type { ChatMessage, Chunk, + ConversationMeta, Logger, ReasoningEffort, Role, @@ -63,6 +64,16 @@ export interface ConversationStore { readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; /** Persist (upsert) the reasoning-effort level for a conversation. */ readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; + /** + * List all known conversations, sorted by `lastActivityAt` descending (most + * recent first). Metadata (createdAt, lastActivityAt, title) is tracked + * automatically on append; title defaults to the first user message. + */ + readonly listConversations: () => 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>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -245,5 +256,12 @@ export function createConversationStore( logger.debug("reasoning-effort set", { conversationId }); } }, + async listConversations() { + return []; + }, + async getConversationMeta(_conversationId: string) { + return null; + }, + async setConversationTitle(_conversationId: string, _title: string) {}, }; } diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index f4a342d..f1f8d77 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -8,6 +8,7 @@ export type { ChatMessage, Chunk, + ConversationMeta, ErrorChunk, Role, StepId, diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts index 4b1350b..d39853e 100644 --- a/packages/kernel/src/contracts/index.ts +++ b/packages/kernel/src/contracts/index.ts @@ -15,6 +15,7 @@ export type { export type { ChatMessage, Chunk, + ConversationMeta, ErrorChunk, Role, StepId, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 39996b0..9ee9704 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -79,6 +79,13 @@ function createInMemoryStore(): ConversationStore & { async setReasoningEffort(conversationId, effort) { effortData.set(conversationId, effort); }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; } @@ -518,6 +525,13 @@ describe("turn-sealed event", () => { async setReasoningEffort(conversationId, effort) { await store.setReasoningEffort(conversationId, effort); }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -573,6 +587,13 @@ describe("turn-sealed event", () => { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -917,6 +938,13 @@ describe("turn metrics persistence", () => { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const { orchestrator } = createSessionOrchestrator({ diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index c1f12da..2aba3d4 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -75,6 +75,13 @@ function createInMemoryStore(): ConversationStore & { async setReasoningEffort(conversationId, effort) { effortData.set(conversationId, effort); }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; } diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index ec7ccd1..0739977 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.12.0", + "version": "0.13.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index e71feb7..d8bfe50 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -22,6 +22,7 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { AgentEvent, + ConversationMeta, QueuedMessage, ReasoningEffort, StoredChunk, @@ -30,6 +31,7 @@ import type { export type { AgentEvent, + ConversationMeta, QueuedMessage, ReasoningEffort, StepMetrics, @@ -477,4 +479,65 @@ export type WsClientMessage = * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ -export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage; +export type WsServerMessage = + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ConversationOpenMessage; + +// ─── Conversation list + metadata ──────────────────────────────────────────── + +/** + * Broadcast to all connected WS clients when a conversation is "opened" (e.g. + * via the CLI `--open` flag). The frontend decides whether to open/focus a tab + * — the backend just signals. Additive to `WsServerMessage`. + */ +export interface ConversationOpenMessage { + readonly type: "conversation.open"; + readonly conversationId: string; +} + +/** + * Response for `GET /conversations` — the list of all known conversations, + * sorted by `lastActivityAt` descending (most recent first). Each entry carries + * enough metadata for a conversation picker UI (id, title, timestamps). + * Optional `?q=` query param filters by id prefix (short-id resolution). + */ +export interface ConversationListResponse { + readonly conversations: readonly ConversationMeta[]; +} + +/** + * Response for `GET /conversations/:id/last` — blocks server-side until the + * in-flight turn settles (if one is active), then returns the last assistant + * text message. `content` is empty if the conversation has no assistant message. + * `turnId` is the turn that produced the message (absent if no turn ran). + */ +export interface LastMessageResponse { + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; +} + +/** + * Response for `POST /conversations/:id/open` — confirms the conversation.open + * signal was broadcast to connected WS clients. + */ +export interface OpenConversationResponse { + readonly conversationId: string; +} + +/** + * Request body for `PUT /conversations/:id/title` — set a human-readable title. + */ +export interface SetTitleRequest { + readonly title: string; +} + +/** + * Response for `GET/PUT /conversations/:id/title` — the current title. + */ +export interface TitleResponse { + readonly conversationId: string; + readonly title: string; +} diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 49e240f..9078a07 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -122,6 +122,13 @@ function createFakeConversationStore( async setReasoningEffort(conversationId, effort) { reasoningEffortStore.set(conversationId, effort); }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; } @@ -832,6 +839,13 @@ describe("GET /conversations/:id", () => { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const app = createApp({ conversationStore: store, @@ -889,6 +903,13 @@ describe("GET /conversations/:id", () => { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const app = createApp({ conversationStore: store, @@ -1015,6 +1036,13 @@ describe("GET /conversations/:id/metrics", () => { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const app = createApp({ conversationStore: brokenStore, @@ -1974,6 +2002,13 @@ describe("PUT /conversations/:id/reasoning-effort", () => { async setReasoningEffort() { storeCalled = true; }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; const app = createApp({ conversationStore: store, diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 3fcc473..9e5f037 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -27,12 +27,16 @@ export const manifest: Manifest = { routes: [ "/chat", "/chat/warm", + "/conversations", "/conversations/:id", "/conversations/:id/close", "/conversations/:id/cwd", + "/conversations/:id/last", "/conversations/:id/lsp", + "/conversations/:id/open", "/conversations/:id/queue", "/conversations/:id/reasoning-effort", + "/conversations/:id/title", "/health", "/models", "/metrics/throughput", diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index 151ad24..3b6ee8f 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -54,6 +54,13 @@ function fakeConversationStore(): ConversationStore { return null; }, async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, }; } diff --git a/packages/wire/package.json b/packages/wire/package.json index b83f127..e8aa662 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/wire", - "version": "0.8.0", + "version": "0.9.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index 3edeabf..72b5e43 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -496,3 +496,18 @@ export interface TurnSteeringEvent { readonly turnId: string; readonly text: string; } + +// ─── Conversation metadata ─────────────────────────────────────────────────── + +/** + * Metadata for a conversation, returned by `GET /conversations` (the list + * endpoint). The title defaults to the first user message (truncated) and can + * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write; + * `lastActivityAt` is updated on every append. + */ +export interface ConversationMeta { + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; +} |
