summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--bun.lock1
-rw-r--r--packages/transport-contract/src/index.ts30
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts129
-rw-r--r--packages/transport-http/src/app.ts27
-rw-r--r--packages/transport-http/src/extension.ts13
-rw-r--r--packages/transport-http/src/index.ts18
-rw-r--r--packages/transport-http/src/logic.test.ts42
-rw-r--r--packages/transport-http/src/logic.ts15
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/tsconfig.json1
-rw-r--r--tasks.md22
12 files changed, 284 insertions, 17 deletions
diff --git a/bun.lock b/bun.lock
index 13a9e6c..b8f9bde 100644
--- a/bun.lock
+++ b/bun.lock
@@ -150,6 +150,7 @@
"name": "@dispatch/transport-http",
"version": "0.0.0",
"dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 7d3996a..9d8f6f4 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -12,7 +12,9 @@
* union, re-exported here so a client has one import for the whole wire.
*/
-export type { AgentEvent } from "@dispatch/wire";
+import type { StoredChunk } from "@dispatch/wire";
+
+export type { AgentEvent, StoredChunk } from "@dispatch/wire";
/**
* Request body for `POST /chat` (sent as JSON).
@@ -55,3 +57,29 @@ export interface ChatRequest {
export interface ModelsResponse {
readonly models: readonly string[];
}
+
+/**
+ * Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental
+ * read-side history endpoint a long-lived client uses to (re)hydrate a
+ * conversation cheaply.
+ *
+ * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log
+ * with `seq > sinceSeq` (or the whole log when `sinceSeq` is omitted/0). It is
+ * NOT reconciled: a dangling tool-call is returned as-is (rendered as an
+ * interrupted call). Reconciliation is a turn-path concern — the server repairs
+ * history only when it feeds a provider, never on this read path — which is what
+ * preserves the per-chunk `seq` cursor invariant (a synthesized repair chunk
+ * would have no seq).
+ *
+ * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the
+ * slice is empty (the client is already caught up) — the requested `sinceSeq`
+ * (0 for a full read of an empty conversation). So after applying the response a
+ * client's new cursor is always `latestSeq`, and an empty `chunks` means
+ * "nothing new past your cursor". (A true server-side high-water mark
+ * independent of the filter is deferred until a consumer needs it — it would
+ * require widening the store contract.)
+ */
+export interface ConversationHistoryResponse {
+ readonly chunks: readonly StoredChunk[];
+ readonly latestSeq: number;
+}
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index eacf39a..5b6846f 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -6,6 +6,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
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";
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index a6d1ca8..bcbf86a 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -3,6 +3,7 @@
"compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
"include": ["src/**/*.ts"],
"references": [
+ { "path": "../conversation-store" },
{ "path": "../credential-store" },
{ "path": "../kernel" },
{ "path": "../session-orchestrator" },
diff --git a/tasks.md b/tasks.md
index 8f27735..af6d020 100644
--- a/tasks.md
+++ b/tasks.md
@@ -393,8 +393,26 @@ streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../
zero internal `@dispatch/*` mocks, both agents in-lane.
Read-side HTTP endpoint (`GET /conversations/:id?sinceSeq=`) + WS turn-deltas remain their own
following steps.
-- [ ] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → reconciled `Chunk[]`/
- `ChatMessage[]` (transport-http + conversation-store).
+- [x] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → RAW seq'd stream. DONE + verified.
+ Design (user-confirmed): returns `ConversationHistoryResponse { chunks: StoredChunk[], latestSeq }`
+ — the RAW, append-order, seq-filtered slice from `conversation-store.loadSince`, **NOT
+ reconciled**. `reconcile` (message-level repair) conflicts with per-chunk `seq` (a synthesized
+ repair chunk has no seq), so reconciliation stays on the TURN path (session-orchestrator before
+ `runTurn`); the read path is a pure sync primitive (FE renders a dangling tool-call as
+ interrupted; FE commits only sealed turns §6.6). `latestSeq` = last returned chunk's seq, else
+ `sinceSeq` (true server high-water mark deferred until a consumer needs it — avoids widening the
+ store contract).
+ - **Contract + wiring (orchestrator):** added `ConversationHistoryResponse` + `StoredChunk`
+ re-export to `@dispatch/transport-contract`; added `@dispatch/conversation-store` dep +
+ project ref to transport-http; `bun install`.
+ - **transport-http (owner, mimo-v2.5-pro):** `GET /conversations/:id?sinceSeq=` route; reaches
+ the log DIRECTLY via `conversationStoreHandle` (`dependsOn:["conversation-store"]`), not through
+ the orchestrator; pure `parseSinceSeq` (absent→0, invalid/negative/float→400) in `logic.ts`;
+ +6 logic + 6 app integration tests (via real Hono `app.fetch`). prompts/read-side-endpoint.md,
+ reports/transport-http.md.
+ - **Verified (orchestrator):** typecheck clean, **481 vitest** (469→+12), biome clean, no internal
+ `@dispatch/*` mocks, in-lane. Live boot-probe deferred to the WS step (this GET route has no
+ effectful-shell surprise surface; host wiring mirrors `/chat`).
- [ ] **WS turn-deltas** — `transport-ws` multiplexes `sendMessage`/`onDelta(AgentEvent)`
alongside surface ops (one connection carries both; frontend-design §5).
Then FE (`../dispatch-web`): `core/transcript` reducer + `conversation-cache` + `chat` feature.