|
1 | 1 | import { AbstractAgent } from "../agent"; |
2 | | -import { AgentSubscriber } from "../subscriber"; |
| 2 | +import { AgentSubscriber, runSubscribersWithMutation } from "../subscriber"; |
3 | 3 | import { |
4 | 4 | BaseEvent, |
5 | 5 | EventType, |
@@ -1261,6 +1261,220 @@ describe("AgentSubscriber", () => { |
1261 | 1261 | }); |
1262 | 1262 | }); |
1263 | 1263 |
|
| 1264 | + describe("runSubscribersWithMutation isolation contract", () => { |
| 1265 | + const runWith = ( |
| 1266 | + subscribers: AgentSubscriber[], |
| 1267 | + messages: Message[] = [{ id: "orig", role: "user", content: "hi" }], |
| 1268 | + state: Record<string, any> = {}, |
| 1269 | + ) => |
| 1270 | + runSubscribersWithMutation(subscribers, messages, state, (subscriber, msgs, st) => |
| 1271 | + subscriber.onEvent?.({ |
| 1272 | + messages: msgs, |
| 1273 | + state: st, |
| 1274 | + agent: {} as any, |
| 1275 | + input: {} as any, |
| 1276 | + event: { type: EventType.RUN_STARTED } as any, |
| 1277 | + }), |
| 1278 | + ); |
| 1279 | + |
| 1280 | + it("should detect changes when subscriber returns a new messages array with same content", async () => { |
| 1281 | + const cloningSubscriber: AgentSubscriber = { |
| 1282 | + onEvent: ({ messages }) => ({ |
| 1283 | + messages: [...messages], |
| 1284 | + }), |
| 1285 | + }; |
| 1286 | + |
| 1287 | + const result = await runWith([cloningSubscriber]); |
| 1288 | + |
| 1289 | + // Reference equality means a new array is detected as a change |
| 1290 | + // (trade-off: may cause extra onMessagesChanged callbacks, but avoids JSON.stringify cost) |
| 1291 | + expect(result.messages).toBeDefined(); |
| 1292 | + }); |
| 1293 | + |
| 1294 | + it("should detect changes when subscriber returns a new state object with same content", async () => { |
| 1295 | + const cloningSubscriber: AgentSubscriber = { |
| 1296 | + onEvent: ({ state }) => ({ |
| 1297 | + state: { ...state }, |
| 1298 | + }), |
| 1299 | + }; |
| 1300 | + |
| 1301 | + const result = await runWith([cloningSubscriber]); |
| 1302 | + |
| 1303 | + expect(result.state).toBeDefined(); |
| 1304 | + }); |
| 1305 | + |
| 1306 | + it("should not report changes when subscriber returns the same messages reference", async () => { |
| 1307 | + const passThroughSubscriber: AgentSubscriber = { |
| 1308 | + onEvent: ({ messages }) => ({ |
| 1309 | + messages: messages as Message[], |
| 1310 | + }), |
| 1311 | + }; |
| 1312 | + |
| 1313 | + const result = await runWith([passThroughSubscriber]); |
| 1314 | + |
| 1315 | + // Same reference returned — detected as no-op, no clone needed |
| 1316 | + expect(result.messages).toBeUndefined(); |
| 1317 | + }); |
| 1318 | + |
| 1319 | + it("should not report changes when subscriber returns the same state reference", async () => { |
| 1320 | + const passThroughSubscriber: AgentSubscriber = { |
| 1321 | + onEvent: ({ state }) => ({ |
| 1322 | + state, |
| 1323 | + }), |
| 1324 | + }; |
| 1325 | + |
| 1326 | + const result = await runWith([passThroughSubscriber]); |
| 1327 | + |
| 1328 | + // Same reference returned — detected as no-op, no clone needed |
| 1329 | + expect(result.state).toBeUndefined(); |
| 1330 | + }); |
| 1331 | + |
| 1332 | + it("should not report in-place mutations that are not returned (production mode)", async () => { |
| 1333 | + // Stub to production mode: no freeze, so the push succeeds, but the mutation is |
| 1334 | + // not communicated via return value and therefore not detected. |
| 1335 | + vi.stubEnv("NODE_ENV", "production"); |
| 1336 | + vi.stubEnv("VITEST_WORKER_ID", ""); |
| 1337 | + |
| 1338 | + try { |
| 1339 | + const inPlaceMutator: AgentSubscriber = { |
| 1340 | + onEvent: ({ messages }) => { |
| 1341 | + // In production mode inputs are not frozen, so push succeeds silently. |
| 1342 | + // But since nothing is returned, the change is not propagated — silent data loss. |
| 1343 | + // This documents the contract: subscribers MUST return mutations, not mutate in-place. |
| 1344 | + (messages as Message[]).push({ |
| 1345 | + id: "injected", |
| 1346 | + role: "assistant", |
| 1347 | + content: "injected", |
| 1348 | + }); |
| 1349 | + // Return void — no mutation communicated |
| 1350 | + }, |
| 1351 | + }; |
| 1352 | + |
| 1353 | + const result = await runWith([inPlaceMutator]); |
| 1354 | + |
| 1355 | + // The in-place push is NOT reflected in the result because it wasn't returned |
| 1356 | + expect(result.messages).toBeUndefined(); |
| 1357 | + } finally { |
| 1358 | + vi.unstubAllEnvs(); |
| 1359 | + } |
| 1360 | + }); |
| 1361 | + |
| 1362 | + it("should re-throw TypeError when subscriber mutates frozen inputs in dev/test mode", async () => { |
| 1363 | + // In dev/test mode, inputs are deep-frozen. Any in-place mutation attempt |
| 1364 | + // throws a TypeError that runSubscribersWithMutation re-throws so violations |
| 1365 | + // are visible rather than silently swallowed. |
| 1366 | + const inPlaceMutator: AgentSubscriber = { |
| 1367 | + onEvent: ({ messages }) => { |
| 1368 | + (messages as Message[]).push({ id: "bad", role: "assistant", content: "bad" }); |
| 1369 | + }, |
| 1370 | + }; |
| 1371 | + |
| 1372 | + await expect(runWith([inPlaceMutator])).rejects.toThrow(TypeError); |
| 1373 | + }); |
| 1374 | + |
| 1375 | + it("should log a freeze violation and continue in development mode (non-test)", async () => { |
| 1376 | + // In dev mode (NODE_ENV=development), freeze violations are logged with a specific |
| 1377 | + // message instead of re-throwing — the violating subscriber is skipped, and remaining |
| 1378 | + // subscribers continue to execute. |
| 1379 | + vi.stubEnv("NODE_ENV", "development"); |
| 1380 | + vi.stubEnv("VITEST_WORKER_ID", ""); |
| 1381 | + |
| 1382 | + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 1383 | + |
| 1384 | + try { |
| 1385 | + const inPlaceMutator: AgentSubscriber = { |
| 1386 | + onEvent: ({ messages }) => { |
| 1387 | + (messages as Message[]).push({ id: "bad", role: "assistant", content: "bad" }); |
| 1388 | + }, |
| 1389 | + }; |
| 1390 | + |
| 1391 | + // Should resolve (not reject) because dev mode logs instead of re-throwing |
| 1392 | + const result = await runWith([inPlaceMutator]); |
| 1393 | + |
| 1394 | + expect(result.messages).toBeUndefined(); |
| 1395 | + expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 1396 | + expect.stringContaining("AG-UI:"), |
| 1397 | + expect.any(TypeError), |
| 1398 | + ); |
| 1399 | + } finally { |
| 1400 | + consoleErrorSpy.mockRestore(); |
| 1401 | + vi.unstubAllEnvs(); |
| 1402 | + } |
| 1403 | + }); |
| 1404 | + |
| 1405 | + it("should return unfrozen messages even after freeze was applied internally", async () => { |
| 1406 | + // Verifies that the internal deepFreeze does not leak frozen references to callers. |
| 1407 | + // The returned AgentStateMutation.messages must be mutable. |
| 1408 | + const mutatingSubscriber: AgentSubscriber = { |
| 1409 | + onEvent: () => ({ |
| 1410 | + messages: [{ id: "new", role: "assistant", content: "new message" }], |
| 1411 | + }), |
| 1412 | + }; |
| 1413 | + |
| 1414 | + // Add a second subscriber that does nothing, so the messages clone gets frozen |
| 1415 | + // on the next iteration — this is the scenario that could return a frozen value. |
| 1416 | + const noopSubscriber: AgentSubscriber = { |
| 1417 | + onEvent: () => undefined, |
| 1418 | + }; |
| 1419 | + |
| 1420 | + const result = await runWith([mutatingSubscriber, noopSubscriber]); |
| 1421 | + |
| 1422 | + expect(result.messages).toBeDefined(); |
| 1423 | + // Must not be frozen — callers should be able to mutate the returned value |
| 1424 | + expect(Object.isFrozen(result.messages)).toBe(false); |
| 1425 | + }); |
| 1426 | + |
| 1427 | + it("should return unfrozen state even after freeze was applied internally", async () => { |
| 1428 | + // Same as the messages variant above, but for state. The state clone at the |
| 1429 | + // return point must also be unfrozen (symmetric code path, separately tested). |
| 1430 | + const mutatingSubscriber: AgentSubscriber = { |
| 1431 | + onEvent: () => ({ |
| 1432 | + state: { key: "value" }, |
| 1433 | + }), |
| 1434 | + }; |
| 1435 | + |
| 1436 | + // Second subscriber triggers the freeze-on-next-iteration path for state. |
| 1437 | + const noopSubscriber: AgentSubscriber = { |
| 1438 | + onEvent: () => undefined, |
| 1439 | + }; |
| 1440 | + |
| 1441 | + const result = await runWith([mutatingSubscriber, noopSubscriber]); |
| 1442 | + |
| 1443 | + expect(result.state).toBeDefined(); |
| 1444 | + expect(Object.isFrozen(result.state)).toBe(false); |
| 1445 | + }); |
| 1446 | + |
| 1447 | + it("should give subscriber B a clone of subscriber A's mutation output", async () => { |
| 1448 | + // Verifies multi-subscriber isolation: the new cloning-on-output strategy |
| 1449 | + // must still ensure each subsequent subscriber sees its own defensive copy, |
| 1450 | + // not a direct reference to the previous subscriber's returned array. |
| 1451 | + let subscriberBReceivedMessages: ReadonlyArray<Readonly<Message>> | undefined; |
| 1452 | + let subscriberAReturnedMessages: Message[] | undefined; |
| 1453 | + |
| 1454 | + const subscriberA: AgentSubscriber = { |
| 1455 | + onEvent: () => { |
| 1456 | + subscriberAReturnedMessages = [{ id: "a-msg", role: "assistant", content: "from A" }]; |
| 1457 | + return { messages: subscriberAReturnedMessages }; |
| 1458 | + }, |
| 1459 | + }; |
| 1460 | + |
| 1461 | + const subscriberB: AgentSubscriber = { |
| 1462 | + onEvent: ({ messages }) => { |
| 1463 | + subscriberBReceivedMessages = messages; |
| 1464 | + }, |
| 1465 | + }; |
| 1466 | + |
| 1467 | + await runWith([subscriberA, subscriberB]); |
| 1468 | + |
| 1469 | + // B should see A's messages content |
| 1470 | + expect(subscriberBReceivedMessages).toBeDefined(); |
| 1471 | + expect(subscriberBReceivedMessages).toHaveLength(1); |
| 1472 | + expect(subscriberBReceivedMessages![0].id).toBe("a-msg"); |
| 1473 | + // But NOT the same reference — B received a defensive clone |
| 1474 | + expect(subscriberBReceivedMessages).not.toBe(subscriberAReturnedMessages); |
| 1475 | + }); |
| 1476 | + }); |
| 1477 | + |
1264 | 1478 | describe("EmptyError Bug Reproduction", () => { |
1265 | 1479 | test("should demonstrate EmptyError with STEP_STARTED/STEP_FINISHED events that cause no mutations", async () => { |
1266 | 1480 | const emptyAgent = new TestAgent(); |
|
0 commit comments