diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 19:53:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 19:53:51 +0900 |
| commit | 61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e (patch) | |
| tree | 47f8a60a0c4c51c1fa8beee2cb90fd54abf91c68 /packages/transport-http/src | |
| parent | 44e27177892a48a51c440676ff3f6613deef5164 (diff) | |
| download | dispatch-61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e.tar.gz dispatch-61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e.zip | |
feat(transport-http): GET /conversations/:id?sinceSeq= read-side history endpoint
Incremental rehydration endpoint for long-lived clients. Returns
ConversationHistoryResponse { chunks: StoredChunk[], latestSeq } — the RAW,
append-order, seq-filtered slice from conversation-store.loadSince, NOT
reconciled (reconcile conflicts with the per-chunk seq cursor, so it stays on
the turn path; the read path is a pure sync primitive).
- transport-contract: add ConversationHistoryResponse + StoredChunk re-export.
- transport-http: GET /conversations/:id route reaching the log directly via
conversationStoreHandle (dependsOn conversation-store); pure parseSinceSeq
(absent->0, invalid->400).
- build wiring: conversation-store dep + project ref.
FE Slice 2 backend prereq (read-side). typecheck clean, 481 vitest, biome clean.
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 129 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 27 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 13 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 18 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 42 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 15 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 |
7 files changed, 232 insertions, 14 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 38089e4..f4707bc 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,7 +1,23 @@ -import type { AgentEvent } from "@dispatch/kernel"; +import type { AgentEvent, StoredChunk } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; -import type { CredentialStore, SessionOrchestrator } from "./seam.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; + +function createFakeConversationStore( + store: Map<string, StoredChunk[]> = new Map(), +): ConversationStore { + return { + async append() {}, + async load() { + return []; + }, + async loadSince(conversationId, sinceSeq) { + const chunks = store.get(conversationId) ?? []; + const minSeq = sinceSeq ?? 0; + return chunks.filter((c) => c.seq > minSeq); + }, + }; +} function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { return { @@ -62,6 +78,7 @@ function createThrowingCredentialStore(error: Error): CredentialStore { describe("GET /health", () => { it("returns ok", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -75,6 +92,7 @@ describe("GET /health", () => { describe("GET /models", () => { it("returns model catalog", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), }); @@ -86,6 +104,7 @@ describe("GET /models", () => { it("returns empty array when no models", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -97,6 +116,7 @@ describe("GET /models", () => { it("returns 502 when listCatalog throws", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createThrowingCredentialStore(new Error("db down")), }); @@ -110,6 +130,7 @@ describe("GET /models", () => { describe("POST /chat", () => { it("returns 400 for invalid JSON", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -123,6 +144,7 @@ describe("POST /chat", () => { it("returns 400 for missing message", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -138,6 +160,7 @@ describe("POST /chat", () => { it("returns 400 for empty message", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -157,6 +180,7 @@ describe("POST /chat", () => { { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]; const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator(events), credentialStore: createFakeCredentialStore([]), }); @@ -185,6 +209,7 @@ describe("POST /chat", () => { it("generates conversationId when not provided", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]), @@ -204,6 +229,7 @@ describe("POST /chat", () => { it("emits error event when orchestrator throws", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), credentialStore: createFakeCredentialStore([]), }); @@ -230,6 +256,7 @@ describe("POST /chat", () => { it("handles empty event list", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -248,6 +275,7 @@ describe("POST /chat", () => { it("forwards modelName and cwd to orchestrator", async () => { const cap = createCapturingOrchestrator(); const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -274,6 +302,7 @@ describe("POST /chat", () => { it("omits modelName and cwd when not provided", async () => { const cap = createCapturingOrchestrator(); const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -290,3 +319,99 @@ describe("POST /chat", () => { expect(cap.received?.cwd).toBeUndefined(); }); }); + +describe("GET /conversations/:id", () => { + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, + ]; + + it("returns the full seq-ordered StoredChunk history", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(4); + expect(body.chunks[0]?.seq).toBe(1); + expect(body.chunks[3]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns only chunks with seq > N and latestSeq = last seq", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(2); + expect(body.chunks[0]?.seq).toBe(3); + expect(body.chunks[1]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=4"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(0); + }); + + it("returns 400 for invalid sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=abc"); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("sinceSeq"); + }); + + it("returns 400 for negative sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=-1"); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index d5492ce..5f63683 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,10 +1,17 @@ import type { AgentEvent } from "@dispatch/kernel"; -import type { ModelsResponse } from "@dispatch/transport-contract"; +import type { ConversationHistoryResponse, ModelsResponse } from "@dispatch/transport-contract"; import { Hono } from "hono"; -import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -import type { CredentialStore, SessionOrchestrator } from "./seam.js"; +import { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; export interface CreateServerOptions { + readonly conversationStore: ConversationStore; readonly orchestrator: SessionOrchestrator; readonly credentialStore: CredentialStore; readonly generateId?: () => string; @@ -16,6 +23,20 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/health", (c) => c.json({ ok: true })); + app.get("/conversations/:id", async (c) => { + const conversationId = c.req.param("id"); + const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); + if (isSinceSeqError(sinceSeqResult)) { + return c.json({ error: sinceSeqResult.error }, 400); + } + + const chunks = await opts.conversationStore.loadSince(conversationId, sinceSeqResult); + const latestSeq = + chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; + const body: ConversationHistoryResponse = { chunks, latestSeq }; + return c.json(body, 200); + }); + app.get("/models", async (c) => { try { const models = await opts.credentialStore.listCatalog(); diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index e9613c5..ba45f9d 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,7 +1,11 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import type { Hono } from "hono"; import { createApp } from "./app.js"; -import { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; +import { + conversationStoreHandle, + credentialStoreHandle, + sessionOrchestratorHandle, +} from "./seam.js"; export const manifest: Manifest = { id: "transport-http", @@ -9,9 +13,9 @@ export const manifest: Manifest = { version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", - dependsOn: ["credential-store", "session-orchestrator"], + dependsOn: ["conversation-store", "credential-store", "session-orchestrator"], capabilities: { network: true }, - contributes: { routes: ["/chat", "/health", "/models"] }, + contributes: { routes: ["/chat", "/conversations/:id", "/health", "/models"] }, activation: "eager", }; @@ -20,9 +24,10 @@ export interface CreateServerOptions { } export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { + const conversationStore = host.getService(conversationStoreHandle); const orchestrator = host.getService(sessionOrchestratorHandle); const credentialStore = host.getService(credentialStoreHandle); - return createApp({ orchestrator, credentialStore }); + return createApp({ conversationStore, orchestrator, credentialStore }); } export const extension: Extension = { diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index d91f9ad..d92319c 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -1,7 +1,17 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; export { createServer, extension, manifest } from "./extension.js"; -export type { ChatCommand, ParseError, ParseResult } from "./logic.js"; -export { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -export type { CredentialStore, SessionOrchestrator } from "./seam.js"; -export { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; +export type { ChatCommand, ParseError, ParseResult, SinceSeqResult } from "./logic.js"; +export { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; +export type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; +export { + conversationStoreHandle, + credentialStoreHandle, + sessionOrchestratorHandle, +} from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index 141549f..1e33f40 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -1,6 +1,12 @@ import type { AgentEvent } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; -import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; +import { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; describe("parseChatBody", () => { const fakeId = () => "test-uuid"; @@ -132,6 +138,40 @@ describe("parseChatBody", () => { }); }); +describe("parseSinceSeq", () => { + it("returns 0 when undefined", () => { + expect(parseSinceSeq(undefined)).toBe(0); + }); + + it("returns 0 when empty string", () => { + expect(parseSinceSeq("")).toBe(0); + }); + + it("parses valid non-negative integer", () => { + expect(parseSinceSeq("0")).toBe(0); + expect(parseSinceSeq("5")).toBe(5); + expect(parseSinceSeq("42")).toBe(42); + }); + + it("returns ParseError for non-integer string", () => { + const result = parseSinceSeq("abc"); + expect(isSinceSeqError(result)).toBe(true); + if (isSinceSeqError(result)) { + expect(result.error).toContain("sinceSeq"); + } + }); + + it("returns ParseError for float", () => { + const result = parseSinceSeq("3.14"); + expect(isSinceSeqError(result)).toBe(true); + }); + + it("returns ParseError for negative integer", () => { + const result = parseSinceSeq("-1"); + expect(isSinceSeqError(result)).toBe(true); + }); +}); + describe("serializeEventLine", () => { it("serializes an event as JSON followed by newline", () => { const event: AgentEvent = { diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 11133a5..f1d5b8c 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -13,6 +13,8 @@ export interface ParseError { export type ParseResult = ChatCommand | ParseError; +export type SinceSeqResult = number | ParseError; + export function parseChatBody(body: unknown, generateId: () => string): ParseResult { if (body === null || typeof body !== "object") { return { error: "Request body must be a JSON object" }; @@ -56,3 +58,16 @@ export function isParseError(result: ParseResult): result is ParseError { export function serializeEventLine(event: AgentEvent): string { return `${JSON.stringify(event)}\n`; } + +export function parseSinceSeq(raw: string | undefined): SinceSeqResult { + if (raw === undefined || raw === "") return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: "sinceSeq must be a non-negative integer" }; + } + return n; +} + +export function isSinceSeqError(result: SinceSeqResult): result is ParseError { + return typeof result === "object"; +} diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index 297fb22..c7bfb74 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -1,3 +1,5 @@ +export type { ConversationStore } from "@dispatch/conversation-store"; +export { conversationStoreHandle } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; export type { SessionOrchestrator } from "@dispatch/session-orchestrator"; |
