summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 18:37:09 +0900
committerAdam Malczewski <[email protected]>2026-06-12 18:37:09 +0900
commitdbf77ba78ff840e0ed5f6294030523fe3ab121fa (patch)
treee768aef3edd0c126212058c3d1433355594c49be /packages/transport-http/src
parent6689eb51b467d8e370f31495840d88661f978168 (diff)
downloaddispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.tar.gz
dispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.zip
feat(history): CR-5 windowed reads — ?limit= / ?beforeSeq= on GET /conversations/:id
Selection sinceSeq < seq < beforeSeq; newest-limit window, ascending; positive- integer validation (400, store never sees an invalid window); 1-based gap-free seq codified as the contractual has-older mechanism (no earliestSeq field). transport-contract 0.9.0->0.10.0, wire 0.6.0->0.6.1 (doc-only). conversation-store +8 tests, transport-http +20; 935 vitest + 112 bun green. Live-verified: 6/6 probe checks OK. FE courier: frontend-history-windowing-handoff.md
Diffstat (limited to 'packages/transport-http/src')
-rw-r--r--packages/transport-http/src/app.test.ts169
-rw-r--r--packages/transport-http/src/app.ts39
-rw-r--r--packages/transport-http/src/index.ts3
-rw-r--r--packages/transport-http/src/logic.test.ts54
-rw-r--r--packages/transport-http/src/logic.ts27
5 files changed, 289 insertions, 3 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index c26a868..88de38f 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -86,10 +86,19 @@ function createFakeConversationStore(
async load() {
return [];
},
- async loadSince(conversationId, sinceSeq) {
+ async loadSince(conversationId, sinceSeq, window) {
const chunks = store.get(conversationId) ?? [];
const minSeq = sinceSeq ?? 0;
- return chunks.filter((c) => c.seq > minSeq);
+ const beforeSeq = window?.beforeSeq;
+ const limit = window?.limit;
+ const selected = chunks.filter(
+ (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
+ );
+ // Window: keep only the NEWEST `limit`, still ascending by seq.
+ if (limit !== undefined && selected.length > limit) {
+ return selected.slice(selected.length - limit);
+ }
+ return selected;
},
async appendMetrics() {},
async loadMetrics(conversationId) {
@@ -710,6 +719,162 @@ describe("GET /conversations/:id", () => {
const res = await app.request("/conversations/conv1?sinceSeq=-1");
expect(res.status).toBe(400);
});
+
+ const sixChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
+ { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
+ { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
+ { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
+ { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
+ ];
+
+ function appWithChunks(chunks: StoredChunk[]) {
+ const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
+ return createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ }
+
+ it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?limit=N with N >= conversation size returns the full log", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=10");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?beforeSeq=S returns only chunks with seq < S", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=3");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
+ expect(body.latestSeq).toBe(2);
+ });
+
+ it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ // selection = seq 1..4; newest 2 = [3, 4]
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ describe("window param validation → 400 and store not called with an invalid window", () => {
+ function appCapturingWindow() {
+ const calls: {
+ readonly sinceSeq: number | undefined;
+ readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
+ }[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince(_conversationId, sinceSeq, window) {
+ calls.push({ sinceSeq, window });
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ return { app, calls };
+ }
+
+ const cases: readonly { readonly name: string; readonly query: string }[] = [
+ { name: "limit=0", query: "limit=0" },
+ { name: "limit=-1", query: "limit=-1" },
+ { name: "limit=abc", query: "limit=abc" },
+ { name: "beforeSeq=0", query: "beforeSeq=0" },
+ { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
+ ];
+
+ for (const { name, query } of cases) {
+ it(`${name} → 400 { error } and loadSince is never called`, async () => {
+ const { app, calls } = appCapturingWindow();
+ const res = await app.request(`/conversations/conv1?${query}`);
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(typeof body.error).toBe("string");
+ expect(body.error.length).toBeGreaterThan(0);
+ expect(calls).toHaveLength(0);
+ });
+ }
+ });
+
+ it("no params → byte-identical to the no-window read (regression guard)", async () => {
+ // Rest-param spy: record how many args the route actually passes, so we
+ // can prove the third (window) arg is OMITTED entirely — not merely
+ // forwarded as undefined — preserving the existing two-arg call shape.
+ const argCounts: number[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
+ argCounts.push(args.length);
+ const sinceSeq = args[1] ?? 0;
+ return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ };
+ const app = createApp({
+ conversationStore: 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.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
+ expect(body.latestSeq).toBe(4);
+ // Called once, with exactly two arguments (no window arg).
+ expect(argCounts).toEqual([2]);
+ });
});
describe("GET /conversations/:id/metrics", () => {
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 11d2850..ae24922 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -17,9 +17,11 @@ import {
computeExpectedCacheRate,
isParseError,
isSinceSeqError,
+ isWindowParamError,
parseChatBody,
parseSinceSeq,
parseWarmBody,
+ parseWindowParam,
serializeEventLine,
} from "./logic.js";
import {
@@ -147,8 +149,43 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json({ error: sinceSeqResult.error }, 400);
}
+ // `limit` / `beforeSeq` are optional positive-integer history-window
+ // params. The store is deliberately forgiving (a 0/negative bound is
+ // treated as ABSENT), so we MUST reject malformed values here and never
+ // forward an invalid window.
+ const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq");
+ if (isWindowParamError(beforeSeqResult)) {
+ log.warn("conversations: invalid beforeSeq", {
+ conversationId,
+ error: beforeSeqResult.error,
+ });
+ return c.json({ error: beforeSeqResult.error }, 400);
+ }
+ const limitResult = parseWindowParam(c.req.query("limit"), "limit");
+ if (isWindowParamError(limitResult)) {
+ log.warn("conversations: invalid limit", {
+ conversationId,
+ error: limitResult.error,
+ });
+ return c.json({ error: limitResult.error }, 400);
+ }
+
+ // Include only the fields actually provided (exactOptionalPropertyTypes),
+ // and omit the window argument entirely when neither was given — keeping
+ // the pre-windowing call shape byte-identical for existing callers.
+ const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined =
+ beforeSeqResult !== undefined || limitResult !== undefined
+ ? {
+ ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}),
+ ...(limitResult !== undefined ? { limit: limitResult } : {}),
+ }
+ : undefined;
+
try {
- const chunks = await opts.conversationStore.loadSince(conversationId, sinceSeqResult);
+ const chunks =
+ window !== undefined
+ ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window)
+ : await opts.conversationStore.loadSince(conversationId, sinceSeqResult);
const latestSeq =
chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult;
log.info("conversations: read", {
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index 718aaef..1a42259 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -7,13 +7,16 @@ export type {
ParseResult,
SinceSeqResult,
WarmBodyParsed,
+ WindowParamResult,
} from "./logic.js";
export {
computeCachePct,
isParseError,
isSinceSeqError,
+ isWindowParamError,
parseChatBody,
parseSinceSeq,
+ parseWindowParam,
serializeEventLine,
} from "./logic.js";
export type {
diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts
index 19b47ef..9a7bda2 100644
--- a/packages/transport-http/src/logic.test.ts
+++ b/packages/transport-http/src/logic.test.ts
@@ -4,8 +4,10 @@ import {
computeExpectedCacheRate,
isParseError,
isSinceSeqError,
+ isWindowParamError,
parseChatBody,
parseSinceSeq,
+ parseWindowParam,
serializeEventLine,
} from "./logic.js";
@@ -173,6 +175,58 @@ describe("parseSinceSeq", () => {
});
});
+describe("parseWindowParam", () => {
+ it("returns undefined (absent) when undefined", () => {
+ expect(parseWindowParam(undefined, "limit")).toBeUndefined();
+ });
+
+ it("returns undefined (absent) when empty string", () => {
+ expect(parseWindowParam("", "limit")).toBeUndefined();
+ });
+
+ it("parses a valid positive integer", () => {
+ expect(parseWindowParam("1", "limit")).toBe(1);
+ expect(parseWindowParam("42", "beforeSeq")).toBe(42);
+ });
+
+ it("returns ParseError for zero (store would treat it as absent)", () => {
+ const result = parseWindowParam("0", "limit");
+ expect(isWindowParamError(result)).toBe(true);
+ if (isWindowParamError(result)) {
+ expect(result.error).toContain("limit");
+ expect(result.error).toContain("positive integer");
+ }
+ });
+
+ it("returns ParseError for a negative integer", () => {
+ expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true);
+ });
+
+ it("returns ParseError for a non-integer", () => {
+ expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true);
+ });
+
+ it("returns ParseError for a non-numeric string", () => {
+ const result = parseWindowParam("abc", "beforeSeq");
+ expect(isWindowParamError(result)).toBe(true);
+ if (isWindowParamError(result)) {
+ expect(result.error).toContain("beforeSeq");
+ }
+ });
+
+ it("names the param in the error message", () => {
+ const limit = parseWindowParam("0", "limit");
+ const before = parseWindowParam("0", "beforeSeq");
+ if (isWindowParamError(limit)) expect(limit.error).toContain("limit");
+ if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq");
+ });
+
+ it("isWindowParamError is false for absent and for a valid number", () => {
+ expect(isWindowParamError(undefined)).toBe(false);
+ expect(isWindowParamError(5)).toBe(false);
+ });
+});
+
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 bddedf0..0008110 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -72,6 +72,33 @@ export function isSinceSeqError(result: SinceSeqResult): result is ParseError {
return typeof result === "object";
}
+/**
+ * Result of parsing an OPTIONAL positive-integer history-window query param
+ * (`limit` / `beforeSeq`): `undefined` = the param was absent (omit it from the
+ * store window), a `number` = a valid positive integer, or a {@link ParseError}
+ * for a malformed / zero / negative value (the route 400s on it).
+ */
+export type WindowParamResult = number | undefined | ParseError;
+
+/**
+ * Parse an optional `limit` / `beforeSeq` query param. Unlike `sinceSeq` these
+ * must be STRICTLY POSITIVE integers when present (zero is rejected, since the
+ * store treats a zero bound as absent and would silently return the full log).
+ * Absent (`undefined` / empty) is the valid "no window" case → `undefined`.
+ */
+export function parseWindowParam(raw: string | undefined, name: string): WindowParamResult {
+ if (raw === undefined || raw === "") return undefined;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n <= 0) {
+ return { error: `${name} must be a positive integer` };
+ }
+ return n;
+}
+
+export function isWindowParamError(result: WindowParamResult): result is ParseError {
+ return typeof result === "object" && result !== null;
+}
+
export interface WarmBodyParsed {
readonly conversationId: string;
readonly model?: string;