1import * as Cloudflare from "@/Cloudflare";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import { Gateway } from "./fixtures/Gateway.ts";
10import ChatPersistenceTestWorker from "./fixtures/ChatPersistenceWorker.ts";
11
12// Fresh `workers.dev` URLs return non-200 (404 / 500 "Script not
13// found") for a few seconds while the edge propagates. Each test uses
14// `HttpClient.filterStatusOk(yield* HttpClient.HttpClient)` so the
15// existing `Effect.retry` rides through these by converting the
16// bad-status response into a retryable Effect failure.
17
18const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
19 providers: Cloudflare.providers(),
20});
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const Stack = Alchemy.Stack(
28 "AiGatewayChatPersistenceStack",
29 {
30 providers: Cloudflare.providers(),
31 state: Cloudflare.state(),
32 },
33 Effect.gen(function* () {
34 const worker = yield* ChatPersistenceTestWorker;
35 const gateway = yield* Gateway;
36 return {
37 gatewayId: gateway.gatewayId,
38 url: worker.url.as<string>(),
39 };
40 }),
41);
42
43const stack = beforeAll(deploy(Stack));
44afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
45
46// Cap exponential backoff at 3s so retries stay bounded when the CF edge is
47// slow (otherwise the geometric blow-up dominates wall time).
48const readinessSchedule = Schedule.min([
49 Schedule.exponential("500 millis"),
50 Schedule.spaced("3 seconds"),
51]);
52
53// `filterStatusOk` turns a cold-start non-200 (e.g. a 500 HTML error page)
54// into a retryable failure, and `catchDefect` promotes any cold-start defect
55// (`Handler does not export a fetch() function.`) into a failure too — so the
56// readiness retry absorbs both while the worker propagates to the edge.
57const retryReady = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
58 eff.pipe(
59 Effect.catchDefect((defect) => Effect.fail(defect)),
60 Effect.retry({ schedule: readinessSchedule, times: 15 }),
61 );
62
63test(
64 "first turn against a fresh thread persists user + assistant messages",
65 Effect.gen(function* () {
66 const out = yield* stack;
67 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
68 const id = `fresh-${Date.now()}`;
69
70 const res = yield* client
71 .get(
72 `${out.url}/chat?id=${id}&prompt=${encodeURIComponent("Say the single word 'pong'.")}`,
73 )
74 .pipe(retryReady);
75 expect(res.status).toBe(200);
76
77 const body = (yield* res.json) as { text: string; turns: number };
78 expect(typeof body.text).toBe("string");
79 expect(body.text.length).toBeGreaterThan(0);
80 // The empty chat starts with no messages; one turn appends the user
81 // prompt and the assistant reply.
82 expect(body.turns).toBe(2);
83 }).pipe(logLevel),
84 {
85 tags: [
86 "provider:cloudflare",
87 "provider:cloudflare:ai",
88 "provider:cloudflare:worker",
89 "live",
90 ],
91 timeout: 180_000,
92 },
93);
94
95test(
96 "persisted chat survives across DO invocations",
97 Effect.gen(function* () {
98 const out = yield* stack;
99 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
100 const id = `memory-${Date.now()}`;
101
102 const r1 = yield* client
103 .get(
104 `${out.url}/chat?id=${id}&prompt=${encodeURIComponent("My name is Sam. Remember it.")}`,
105 )
106 .pipe(retryReady);
107 expect(r1.status).toBe(200);
108 const b1 = (yield* r1.json) as { text: string; turns: number };
109 expect(b1.turns).toBe(2);
110
111 // Second request hits the same DO instance (same `id`). The chat
112 // history is reloaded from `state.storage`, so the model can recall
113 // the name from the first turn.
114 const r2 = yield* client
115 .get(
116 `${out.url}/chat?id=${id}&prompt=${encodeURIComponent("What is my name? Answer with just the name.")}`,
117 )
118 .pipe(retryReady);
119 expect(r2.status).toBe(200);
120 const b2 = (yield* r2.json) as { text: string; turns: number };
121
122 expect(b2.text.toLowerCase()).toContain("sam");
123 // The second turn appends another user + assistant pair on top of the
124 // restored history.
125 expect(b2.turns).toBe(4);
126 }).pipe(logLevel),
127 {
128 tags: [
129 "provider:cloudflare",
130 "provider:cloudflare:ai",
131 "provider:cloudflare:worker",
132 "live",
133 ],
134 timeout: 240_000,
135 },
136);
137
138test(
139 "distinct thread ids map to isolated histories",
140 Effect.gen(function* () {
141 const out = yield* stack;
142 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
143 const stamp = Date.now();
144 const idA = `iso-a-${stamp}`;
145 const idB = `iso-b-${stamp}`;
146
147 const ra = yield* client
148 .get(
149 `${out.url}/chat?id=${idA}&prompt=${encodeURIComponent("My favorite color is teal. Remember it.")}`,
150 )
151 .pipe(retryReady);
152 expect(ra.status).toBe(200);
153 expect(((yield* ra.json) as { turns: number }).turns).toBe(2);
154
155 // A different id is a different DO instance with its own storage, so
156 // its first turn also starts from an empty history (turns === 2),
157 // proving the two threads don't share state.
158 const rb = yield* client
159 .get(
160 `${out.url}/chat?id=${idB}&prompt=${encodeURIComponent("Say the single word 'pong'.")}`,
161 )
162 .pipe(retryReady);
163 expect(rb.status).toBe(200);
164 expect(((yield* rb.json) as { turns: number }).turns).toBe(2);
165 }).pipe(logLevel),
166 {
167 tags: [
168 "provider:cloudflare",
169 "provider:cloudflare:ai",
170 "provider:cloudflare:worker",
171 "live",
172 ],
173 timeout: 180_000,
174 },
175);