assistant-stream
Version:
Streaming utilities for AI assistants
419 lines (376 loc) • 14.3 kB
text/typescript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createClient, type RedisClientType } from "redis";
import IoRedis from "ioredis";
import { createRedisResumableStreamStore } from "./redis";
import { createIoredisResumableStreamStore } from "./ioredis";
import type { ResumableStreamStore } from "../types";
// Redis adapter tests are skipped unless a reachable Redis is signaled via
// REDIS_URL or REDIS_TESTS=1. CI without a redis service is the default.
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://127.0.0.1:6379";
const REDIS_TESTS_DISABLED =
process.env["REDIS_URL"] === undefined && process.env["REDIS_TESTS"] !== "1";
// The peer union is only honest if each vitest leg loads the major it claims:
// a broken alias would silently re-run the v6 suite and report both covered.
it("loads the ioredis major this leg advertises", async () => {
const { version } = (await import("ioredis/package.json")) as unknown as {
version: string;
};
expect(version.split(".")[0]).toBe(process.env["IOREDIS_PEER_MAJOR"]);
});
const enc = new TextEncoder();
const dec = new TextDecoder();
const bytes = (s: string): Uint8Array => enc.encode(s);
const text = (b: Uint8Array): string => dec.decode(b);
const KEY_PREFIX_BASE = `aui:resumable:test:${Date.now()}`;
let suiteCounter = 0;
type Adapter = {
name: string;
setup: () => Promise<{
store: ResumableStreamStore;
keyPrefix: string;
makeStore: (
overrides?: Partial<{
maxChunkBytes: number;
}>,
) => ResumableStreamStore;
deleteKey: (key: string) => Promise<void>;
pauseNextGet: (pause: () => Promise<void>) => void;
cleanup: () => Promise<void>;
}>;
};
function withPausableGet<
T extends { get(key: string): Promise<string | null> },
>(
client: T,
): { client: T; pauseNextGet: (pause: () => Promise<void>) => void } {
let nextGetPause: (() => Promise<void>) | undefined;
return {
client: new Proxy(client, {
get(target, property) {
if (property === "get") {
return async (key: string) => {
const value = await client.get(key);
const pause = nextGetPause;
nextGetPause = undefined;
await pause?.();
return value;
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === "function" ? value.bind(target) : value;
},
}),
pauseNextGet(pause) {
nextGetPause = pause;
},
};
}
const adapters: Adapter[] = [
{
name: "node-redis",
setup: async () => {
const client: RedisClientType = createClient({ url: REDIS_URL });
client.on("error", () => {});
await client.connect();
const pausable = withPausableGet(client);
const keyPrefix = `${KEY_PREFIX_BASE}:nr:${suiteCounter++}`;
const makeStore = (overrides?: { maxChunkBytes?: number }) =>
createRedisResumableStreamStore(pausable.client, {
keyPrefix,
pollIntervalMs: 25,
...overrides,
});
return {
store: makeStore(),
keyPrefix,
makeStore,
deleteKey: async (key) => {
await client.del(key);
},
pauseNextGet: pausable.pauseNextGet,
cleanup: async () => {
const keys = await client.keys(`${keyPrefix}:*`);
if (keys.length > 0) await client.del(keys);
await client.quit();
},
};
},
},
{
name: "ioredis",
setup: async () => {
const client = new IoRedis(REDIS_URL, { lazyConnect: true });
await client.connect();
const pausable = withPausableGet(client);
const keyPrefix = `${KEY_PREFIX_BASE}:io:${suiteCounter++}`;
const makeStore = (overrides?: { maxChunkBytes?: number }) =>
createIoredisResumableStreamStore(pausable.client, {
keyPrefix,
pollIntervalMs: 25,
...overrides,
});
return {
store: makeStore(),
keyPrefix,
makeStore,
deleteKey: async (key) => {
await client.del(key);
},
pauseNextGet: pausable.pauseNextGet,
cleanup: async () => {
const keys = await client.keys(`${keyPrefix}:*`);
if (keys.length > 0) await client.del(...keys);
await client.quit();
},
};
},
},
];
for (const adapter of adapters) {
describe.skipIf(REDIS_TESTS_DISABLED)(
`Redis adapter: ${adapter.name}`,
() => {
let store: ResumableStreamStore;
let makeStore: (overrides?: {
maxChunkBytes?: number;
}) => ResumableStreamStore;
let keyPrefix: string;
let deleteKey: (key: string) => Promise<void>;
let pauseNextGet: (pause: () => Promise<void>) => void;
let cleanup: () => Promise<void>;
beforeAll(async () => {
const ctx = await adapter.setup();
store = ctx.store;
makeStore = ctx.makeStore;
keyPrefix = ctx.keyPrefix;
deleteKey = ctx.deleteKey;
pauseNextGet = ctx.pauseNextGet;
cleanup = ctx.cleanup;
});
afterAll(async () => {
await cleanup();
});
it("elects exactly one producer per id", async () => {
const id = `id-${Math.random()}`;
const first = await store.acquire(id);
const second = await store.acquire(id);
expect(first).toBe("producer");
expect(second).toBe("consumer");
});
it("replays buffered chunks and tails until done", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.append(id, bytes("hello"));
await store.append(id, bytes(" world"));
const ac = new AbortController();
const collected: string[] = [];
const reading = (async () => {
for await (const entry of store.read(id, "", ac.signal)) {
collected.push(text(entry.chunk));
if (collected.length === 3) {
await store.finalize(id, "done");
}
}
})();
await new Promise((r) => setTimeout(r, 50));
await store.append(id, bytes("!"));
await reading;
expect(collected.join("")).toBe("hello world!");
});
it("status: missing → streaming → done", async () => {
const id = `id-${Math.random()}`;
expect(await store.status(id)).toBe("missing");
await store.acquire(id);
expect(await store.status(id)).toBe("streaming");
await store.finalize(id, "done");
expect(await store.status(id)).toBe("done");
});
it("status: error finalize", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.finalize(id, "error", "boom");
expect(await store.status(id)).toBe("error");
});
it("does not let a stale finalizer overwrite a reacquired stream", async () => {
const id = `id-${Math.random()}`;
const staleStore = makeStore();
const freshStore = makeStore();
await staleStore.acquire(id);
let resumeFinalizer!: () => void;
const finalizerPaused = new Promise<void>((resolve) => {
pauseNextGet(
() =>
new Promise<void>((resume) => {
resumeFinalizer = resume;
resolve();
}),
);
});
const finalizing = staleStore.finalize(id, "done");
await finalizerPaused;
await deleteKey(`${keyPrefix}:{${id}}:meta`);
await expect(freshStore.acquire(id)).resolves.toBe("producer");
resumeFinalizer();
await expect(finalizing).resolves.toBe(false);
await expect(freshStore.status(id)).resolves.toBe("streaming");
await expect(staleStore.append(id, bytes("stale"))).rejects.toThrow(
/superseded/,
);
await expect(freshStore.finalize(id, "done")).resolves.toBe(true);
});
it("does not let an in-flight append mutate a reacquired stream", async () => {
const id = `id-${Math.random()}`;
const staleStore = makeStore();
const freshStore = makeStore();
await staleStore.acquire(id);
let resumeAppend!: () => void;
const appendPaused = new Promise<void>((resolve) => {
pauseNextGet(
() =>
new Promise<void>((resume) => {
resumeAppend = resume;
resolve();
}),
);
});
const appending = staleStore.append(id, bytes("stale"));
await appendPaused;
await deleteKey(`${keyPrefix}:{${id}}:meta`);
await expect(freshStore.acquire(id)).resolves.toBe("producer");
resumeAppend();
await expect(appending).rejects.toThrow(/superseded/);
await freshStore.append(id, bytes("fresh"));
await freshStore.finalize(id, "done");
const chunks: string[] = [];
for await (const entry of freshStore.read(
id,
"",
new AbortController().signal,
)) {
chunks.push(text(entry.chunk));
}
expect(chunks).toEqual(["fresh"]);
});
it("does not let an in-flight delete remove a reacquired stream", async () => {
const id = `id-${Math.random()}`;
const staleStore = makeStore();
const freshStore = makeStore();
await staleStore.acquire(id);
let resumeDelete!: () => void;
const deletePaused = new Promise<void>((resolve) => {
pauseNextGet(
() =>
new Promise<void>((resume) => {
resumeDelete = resume;
resolve();
}),
);
});
const deleting = staleStore.delete(id);
await deletePaused;
await deleteKey(`${keyPrefix}:{${id}}:meta`);
await expect(freshStore.acquire(id)).resolves.toBe("producer");
resumeDelete();
await deleting;
await expect(freshStore.status(id)).resolves.toBe("streaming");
await staleStore.delete(id);
await expect(freshStore.status(id)).resolves.toBe("missing");
});
it("read throws on error finalize after draining buffered entries", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.append(id, bytes("partial"));
await store.finalize(id, "error", "boom");
const ac = new AbortController();
const seen: string[] = [];
await expect(async () => {
for await (const entry of store.read(id, "", ac.signal)) {
seen.push(text(entry.chunk));
}
}).rejects.toThrow("boom");
expect(seen).toEqual(["partial"]);
});
it("late consumer replays after done", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.append(id, bytes("a"));
await store.append(id, bytes("b"));
await store.append(id, bytes("c"));
await store.finalize(id, "done");
const ac = new AbortController();
const out: string[] = [];
for await (const entry of store.read(id, "", ac.signal)) {
out.push(text(entry.chunk));
}
expect(out).toEqual(["a", "b", "c"]);
});
it("cursor advances and skips already-seen entries", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.append(id, bytes("1"));
await store.append(id, bytes("2"));
await store.append(id, bytes("3"));
await store.finalize(id, "done");
const ac = new AbortController();
const seen: { cursor: string; text: string }[] = [];
for await (const entry of store.read(id, "", ac.signal)) {
seen.push({ cursor: entry.cursor, text: text(entry.chunk) });
}
expect(seen.map((s) => s.text)).toEqual(["1", "2", "3"]);
const out: string[] = [];
for await (const entry of store.read(id, seen[0]!.cursor, ac.signal)) {
out.push(text(entry.chunk));
}
expect(out).toEqual(["2", "3"]);
});
it("delete removes a stream", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
await store.append(id, bytes("x"));
expect(await store.status(id)).toBe("streaming");
await store.delete(id);
expect(await store.status(id)).toBe("missing");
});
it("preserves arbitrary binary bytes through pipelined append", async () => {
const id = `id-${Math.random()}`;
await store.acquire(id);
const producer = new Uint8Array(512);
for (let i = 0; i < producer.length; i++) producer[i] = i & 0xff;
const half = producer.length / 2;
await store.append(id, producer.slice(0, half));
await store.append(id, producer.slice(half));
await store.finalize(id, "done");
const ac = new AbortController();
const replayed: Uint8Array[] = [];
for await (const entry of store.read(id, "", ac.signal)) {
replayed.push(entry.chunk);
}
const total = replayed.reduce((n, c) => n + c.byteLength, 0);
const flat = new Uint8Array(total);
let offset = 0;
for (const c of replayed) {
flat.set(c, offset);
offset += c.byteLength;
}
expect(flat.byteLength).toBe(producer.byteLength);
expect(Array.from(flat)).toEqual(Array.from(producer));
});
it("rejects appends larger than maxChunkBytes", async () => {
const limited = makeStore({ maxChunkBytes: 4 });
const id = `id-${Math.random()}`;
await limited.acquire(id);
await expect(limited.append(id, bytes("12345"))).rejects.toThrow(
/maxChunkBytes/,
);
await limited.append(id, bytes("ok"));
await limited.finalize(id, "done");
const ac = new AbortController();
const out: string[] = [];
for await (const entry of limited.read(id, "", ac.signal)) {
out.push(text(entry.chunk));
}
expect(out).toEqual(["ok"]);
});
},
);
}