summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 16:19:17 +0900
committerAdam Malczewski <[email protected]>2026-06-21 16:19:17 +0900
commit91deffaefb1240d6051c24c9afb7d51beab57e5f (patch)
treeafbf8f138c1a852638ca73aae12849687478a586
parent6cfe0dc1df99c46407b0784576c3d4b0f1cb7349 (diff)
downloaddispatch-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.ts18
-rw-r--r--packages/kernel/src/contracts/conversation.ts1
-rw-r--r--packages/kernel/src/contracts/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts28
-rw-r--r--packages/session-orchestrator/src/queue.test.ts7
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts65
-rw-r--r--packages/transport-http/src/app.test.ts35
-rw-r--r--packages/transport-http/src/extension.ts4
-rw-r--r--packages/transport-http/src/server.bun.test.ts7
-rw-r--r--packages/wire/package.json2
-rw-r--r--packages/wire/src/index.ts15
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;
+}