summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/app.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 19:53:51 +0900
committerAdam Malczewski <[email protected]>2026-06-06 19:53:51 +0900
commit61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e (patch)
tree47f8a60a0c4c51c1fa8beee2cb90fd54abf91c68 /packages/transport-http/src/app.test.ts
parent44e27177892a48a51c440676ff3f6613deef5164 (diff)
downloaddispatch-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/app.test.ts')
-rw-r--r--packages/transport-http/src/app.test.ts129
1 files changed, 127 insertions, 2 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);
+ });
+});