diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 16:56:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 16:56:42 +0900 |
| commit | ea0e938eca3072649dc8707c999ec00cf87b986a (patch) | |
| tree | 41f96ed517fe779ed612fc4cb3e8359a841ad088 /packages/transport-http/src/app.ts | |
| parent | 36e950ba2cd2591e86f0dcc898f740481c59d912 (diff) | |
| download | dispatch-ea0e938eca3072649dc8707c999ec00cf87b986a.tar.gz dispatch-ea0e938eca3072649dc8707c999ec00cf87b986a.zip | |
feat(transport): CLI endpoints + conversation.open broadcast (Wave 2)
transport-http: GET /conversations (list with ?q= prefix filter),
GET /conversations/:id/last (blocks until turn settles, returns last AI
text), POST /conversations/:id/open (emits conversationOpened hook),
PUT /conversations/:id/title (set title). emit threaded from host.emit.
extractLastAssistantText pure helper. 21 new tests (166 total).
transport-ws: subscribes to conversationOpened hook, broadcasts
ConversationOpenMessage to all connected WS clients. 2 new tests.
session-orchestrator: conversationOpened hook descriptor (exported).
Diffstat (limited to 'packages/transport-http/src/app.ts')
| -rw-r--r-- | packages/transport-http/src/app.ts | 140 |
1 files changed, 139 insertions, 1 deletions
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 8cb85c9..86bac0d 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,15 +1,19 @@ -import type { AgentEvent, Logger } from "@dispatch/kernel"; +import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import type { CloseConversationResponse, ConversationHistoryResponse, + ConversationListResponse, ConversationMetricsResponse, CwdResponse, + LastMessageResponse, LspServerInfo, LspStatusResponse, ModelsResponse, + OpenConversationResponse, QueueResponse, ReasoningEffortResponse, ThroughputResponse, + TitleResponse, WarmResponse, } from "@dispatch/transport-contract"; import { Hono } from "hono"; @@ -17,6 +21,7 @@ import { cors } from "hono/cors"; import { computeCachePct, computeExpectedCacheRate, + extractLastAssistantText, isParseError, isReasoningEffortParseError, isSinceSeqError, @@ -32,6 +37,7 @@ import { import { type ConversationStore, type CredentialStore, + conversationOpened, type LspServerStatus, type LspService, type SessionOrchestrator, @@ -52,6 +58,12 @@ export interface CreateServerOptions { readonly generateId?: () => string; /** Injectable clock for sample timestamps (default Date.now). */ readonly now?: () => number; + /** + * Fire-and-forget event-bus emit (bound `host.emit`). Required by + * `POST /conversations/:id/open` to signal the frontend. When absent, + * that endpoint responds `500 { error: "not available" }`. + */ + readonly emit?: HostAPI["emit"]; } const noopLogger: Logger = { @@ -531,5 +543,131 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + app.get("/conversations", async (c) => { + try { + const all = await opts.conversationStore.listConversations(); + // Optional `?q=` filters by id prefix (short-id resolution). A + // missing/empty/whitespace-only `q` is ignored → return all. + const rawQ = c.req.query("q"); + const q = rawQ?.trim() ?? ""; + const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all; + log.info("conversations: list", { + count: conversations.length, + ...(q.length > 0 ? { q } : {}), + }); + const body: ConversationListResponse = { conversations }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: list failure", { err }); + return c.json({ error: "Failed to list conversations" }, 500); + } + }); + + app.get("/conversations/:id/last", async (c) => { + const conversationId = c.req.param("id"); + + // Subscribe BEFORE checking isActive — closes the race where a seal + // fires between the check and the subscribe (we'd miss it). If idle, + // unsubscribe immediately; if active, wait for a `turn-sealed` event + // (or a 60s timeout, then proceed regardless of what's available). + let turnId: string | undefined; + let unsubscribe: (() => void) | undefined; + try { + await new Promise<void>((resolve) => { + let settled = false; + let timer: ReturnType<typeof setTimeout> | undefined; + const finish = (): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(); + }; + unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => { + if (event.type === "turn-sealed") { + turnId = event.turnId; + finish(); + } + }); + if (!opts.orchestrator.isActive(conversationId)) { + finish(); + return; + } + // A seal may have fired synchronously during subscribe (the + // real orchestrator never does this, but a fake might) — don't + // arm a 60s timer for an already-settled promise. + if (settled) return; + timer = setTimeout(finish, 60_000); + }); + } finally { + unsubscribe?.(); + } + + let content = ""; + try { + const messages = await opts.conversationStore.load(conversationId); + content = extractLastAssistantText(messages); + } catch (err) { + log.error("conversations: last message load failure", { err }); + return c.json({ error: "Failed to load conversation" }, 500); + } + + log.info("conversations: last read", { + conversationId, + hasContent: content.length > 0, + }); + const body: LastMessageResponse = { + conversationId, + content, + ...(turnId !== undefined ? { turnId } : {}), + }; + return c.json(body, 200); + }); + + app.post("/conversations/:id/open", (c) => { + const conversationId = c.req.param("id"); + if (opts.emit === undefined) { + log.warn("conversations: open requested but emit is not available", { + conversationId, + }); + return c.json({ error: "not available" }, 500); + } + opts.emit(conversationOpened, { conversationId }); + log.info("conversations: opened", { conversationId }); + const body: OpenConversationResponse = { conversationId }; + return c.json(body, 200); + }); + + app.put("/conversations/:id/title", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/title: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + if (typeof obj.title !== "string" || obj.title.trim().length === 0) { + return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); + } + // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody` + // forward trimmed text), so a title never carries surrounding whitespace. + const title = obj.title.trim(); + + try { + await opts.conversationStore.setConversationTitle(conversationId, title); + log.info("conversations: title set", { conversationId }); + const response: TitleResponse = { conversationId, title }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: title set failure", { err }); + return c.json({ error: "Failed to set conversation title" }, 500); + } + }); + return app; } |
