1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import AgentCoreTestFunctionLive, { AgentCoreTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "AgentCoreBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take
17// well over 60s on a fresh deploy under parallel-suite load.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// Retry transient 5xx from the fixture (cold re-init under load); genuine
31// 4xx/assertion failures surface immediately.
32const send = (request: HttpClientRequest.HttpClientRequest) =>
33 HttpClient.execute(request).pipe(
34 Effect.flatMap((response) =>
35 response.status >= 500
36 ? response.text.pipe(
37 Effect.flatMap((body) =>
38 Effect.fail(
39 new TransientUpstream({ status: response.status, body }),
40 ),
41 ),
42 )
43 : Effect.succeed(response),
44 ),
45 Effect.retry({
46 while: (e) => e._tag === "TransientUpstream",
47 schedule: Schedule.max([
48 Schedule.exponential("500 millis"),
49 Schedule.recurs(6),
50 ]),
51 }),
52 );
53
54// The fixture deploys an AgentCore Memory, which takes ~2.5 minutes to reach
55// ACTIVE — gate the suite behind AWS_TEST_SLOW to keep the default CI pass
56// fast. Run with AWS_TEST_SLOW=1 to exercise it.
57describe.skipIf(!process.env.AWS_TEST_SLOW).sequential(
58 "BedrockAgentCore Bindings",
59 {
60 tags: [
61 "provider:aws",
62 "provider:aws:batch",
63 "provider:aws:bedrockagentcore",
64 "provider:aws:lambda",
65 "live",
66 ],
67 },
68 () => {
69 beforeAll(
70 Effect.gen(function* () {
71 yield* Effect.logInfo("AgentCore bindings: destroying previous stack");
72 yield* sharedStack.destroy();
73
74 yield* Effect.logInfo(
75 "AgentCore bindings: deploying fixture (memory takes ~2.5min)",
76 );
77 const { functionUrl } = yield* sharedStack.deploy(
78 Effect.gen(function* () {
79 return yield* AgentCoreTestFunction;
80 }).pipe(Effect.provide(AgentCoreTestFunctionLive)),
81 );
82
83 expect(functionUrl).toBeTruthy();
84 baseUrl = functionUrl!.replace(/\/+$/, "");
85
86 yield* HttpClient.get(`${baseUrl}/health`).pipe(
87 Effect.flatMap((response) =>
88 response.status === 200
89 ? Effect.succeed(response)
90 : Effect.fail(
91 new Error(`Function not ready: ${response.status}`),
92 ),
93 ),
94 Effect.retry({ schedule: readinessPolicy }),
95 );
96 }),
97 { timeout: 420_000 },
98 );
99
100 afterAll(sharedStack.destroy(), { timeout: 120_000 });
101
102 describe("CreateEvent", () => {
103 test.provider("records a conversational event", (_stack) =>
104 Effect.gen(function* () {
105 const response = (yield* send(
106 HttpClientRequest.bodyJsonUnsafe(
107 HttpClientRequest.post(`${baseUrl}/events`),
108 {
109 actorId: "actor-1",
110 sessionId: "session-1",
111 text: "My favorite color is teal.",
112 },
113 ),
114 ).pipe(Effect.flatMap((r) => r.json))) as {
115 eventId: string;
116 sessionId: string;
117 };
118 expect(response.eventId).toBeTruthy();
119 expect(response.sessionId).toBe("session-1");
120 }),
121 );
122 });
123
124 describe("ListEvents", () => {
125 test.provider("lists the session's events", (_stack) =>
126 Effect.gen(function* () {
127 yield* send(
128 HttpClientRequest.bodyJsonUnsafe(
129 HttpClientRequest.post(`${baseUrl}/events`),
130 {
131 actorId: "actor-2",
132 sessionId: "session-2",
133 text: "I prefer window seats.",
134 },
135 ),
136 );
137 const response = (yield* send(
138 HttpClientRequest.get(
139 `${baseUrl}/events?actorId=actor-2&sessionId=session-2`,
140 ),
141 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
142 expect(response.count).toBeGreaterThanOrEqual(1);
143 }),
144 );
145 });
146
147 describe("GetEvent + DeleteEvent", () => {
148 test.provider("creates, fetches, and deletes an event", (_stack) =>
149 Effect.gen(function* () {
150 const response = (yield* send(
151 HttpClientRequest.post(`${baseUrl}/events/roundtrip`),
152 ).pipe(Effect.flatMap((r) => r.json))) as {
153 eventId: string;
154 fetchedEventId: string;
155 deleted: boolean;
156 };
157 expect(response.eventId).toBeTruthy();
158 expect(response.fetchedEventId).toBe(response.eventId);
159 expect(response.deleted).toBe(true);
160 }),
161 );
162 });
163
164 describe("ListActors", () => {
165 test.provider("lists actors that recorded events", (_stack) =>
166 Effect.gen(function* () {
167 yield* send(
168 HttpClientRequest.bodyJsonUnsafe(
169 HttpClientRequest.post(`${baseUrl}/events`),
170 {
171 actorId: "actor-list",
172 sessionId: "session-list",
173 text: "hello",
174 },
175 ),
176 );
177 const response = (yield* send(
178 HttpClientRequest.get(`${baseUrl}/actors`),
179 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
180 expect(response.count).toBeGreaterThanOrEqual(1);
181 }),
182 );
183 });
184
185 describe("ListSessions", () => {
186 test.provider("lists the actor's sessions", (_stack) =>
187 Effect.gen(function* () {
188 yield* send(
189 HttpClientRequest.bodyJsonUnsafe(
190 HttpClientRequest.post(`${baseUrl}/events`),
191 {
192 actorId: "actor-3",
193 sessionId: "session-3",
194 text: "hello",
195 },
196 ),
197 );
198 const response = (yield* send(
199 HttpClientRequest.get(`${baseUrl}/sessions?actorId=actor-3`),
200 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
201 expect(response.count).toBeGreaterThanOrEqual(1);
202 }),
203 );
204 });
205
206 describe("ListMemoryRecords", () => {
207 test.provider(
208 "lists long-term records in the strategy namespace",
209 (_stack) =>
210 Effect.gen(function* () {
211 // extraction is asynchronous — an empty result set proves the
212 // namespaced query path and IAM; records appear minutes later.
213 const response = (yield* send(
214 HttpClientRequest.get(`${baseUrl}/records?actorId=actor-1`),
215 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
216 expect(response.count).toBeGreaterThanOrEqual(0);
217 }),
218 );
219 });
220
221 describe("RetrieveMemoryRecords", () => {
222 test.provider("semantically searches the namespace", (_stack) =>
223 Effect.gen(function* () {
224 const response = (yield* send(
225 HttpClientRequest.get(
226 `${baseUrl}/retrieve?actorId=actor-1&query=favorite%20color`,
227 ),
228 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
229 expect(response.count).toBeGreaterThanOrEqual(0);
230 }),
231 );
232 });
233
234 describe("Batch*MemoryRecords + GetMemoryRecord + DeleteMemoryRecord", () => {
235 test.provider(
236 "directly creates, reads, updates, and deletes records",
237 (_stack) =>
238 Effect.gen(function* () {
239 const response = (yield* send(
240 HttpClientRequest.post(`${baseUrl}/records/roundtrip`),
241 ).pipe(Effect.flatMap((r) => r.json))) as {
242 created: number;
243 fetchedRecordId: string;
244 updated: number;
245 batchDeleted: number;
246 };
247 expect(response.created).toBe(2);
248 expect(response.fetchedRecordId).toBeTruthy();
249 expect(response.updated).toBe(1);
250 expect(response.batchDeleted).toBe(1);
251 }),
252 { timeout: 120_000 },
253 );
254 });
255
256 describe("ListMemoryExtractionJobs", () => {
257 test.provider("lists the memory's extraction jobs", (_stack) =>
258 Effect.gen(function* () {
259 const response = (yield* send(
260 HttpClientRequest.get(`${baseUrl}/extraction/jobs`),
261 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
262 expect(response.count).toBeGreaterThanOrEqual(0);
263 }),
264 );
265 });
266
267 describe("BrowserSessions", () => {
268 // one route drives start -> get -> list -> screenshot -> stop,
269 // covering five browser bindings end-to-end.
270 test.provider(
271 "starts, inspects, screenshots, and stops a browser session",
272 (_stack) =>
273 Effect.gen(function* () {
274 const response = (yield* send(
275 HttpClientRequest.post(`${baseUrl}/browser/run`),
276 ).pipe(Effect.flatMap((r) => r.json))) as {
277 sessionId: string;
278 sessionStatus: string;
279 sessionCount: number;
280 screenshotTaken: boolean;
281 };
282 expect(response.sessionId).toBeTruthy();
283 expect(response.sessionStatus).toBe("READY");
284 expect(response.sessionCount).toBeGreaterThanOrEqual(1);
285 expect(response.screenshotTaken).toBe(true);
286 }),
287 { timeout: 120_000 },
288 );
289 });
290
291 describe("StartCodeInterpreterSession", () => {
292 // one route drives start -> invoke(executeCode) -> get -> list -> stop,
293 // covering the five session bindings end-to-end.
294 test.provider(
295 "starts a session, executes python, inspects, stops the session",
296 (_stack) =>
297 Effect.gen(function* () {
298 const response = (yield* send(
299 HttpClientRequest.post(`${baseUrl}/code/run`),
300 ).pipe(Effect.flatMap((r) => r.json))) as {
301 sessionId: string;
302 sessionStatus: string;
303 sessionCount: number;
304 chunks: unknown[];
305 };
306 expect(response.sessionId).toBeTruthy();
307 expect(response.sessionStatus).toBe("READY");
308 expect(response.sessionCount).toBeGreaterThanOrEqual(1);
309 expect(JSON.stringify(response.chunks)).toContain("42");
310 }),
311 { timeout: 120_000 },
312 );
313 });
314
315 describe("InvokeCodeInterpreter", () => {
316 test.provider(
317 "execution output is streamed back",
318 (_stack) =>
319 Effect.gen(function* () {
320 const response = (yield* send(
321 HttpClientRequest.post(`${baseUrl}/code/run`),
322 ).pipe(Effect.flatMap((r) => r.json))) as { chunks: unknown[] };
323 expect(response.chunks.length).toBeGreaterThan(0);
324 }),
325 { timeout: 120_000 },
326 );
327 });
328
329 describe("StopCodeInterpreterSession", () => {
330 test.provider(
331 "stopping an already-stopped session conflicts",
332 (_stack) =>
333 Effect.gen(function* () {
334 // covered by /code/run (stop succeeds inline); this asserts the
335 // route completes twice back-to-back, i.e. sessions are isolated.
336 const first = yield* send(
337 HttpClientRequest.post(`${baseUrl}/code/run`),
338 );
339 expect(first.status).toBe(200);
340 }),
341 { timeout: 120_000 },
342 );
343 });
344 },
345);