Skip to content

Commit a027eac

Browse files
authored
Merge pull request #1395 from ag-ui-protocol/perf/structured-clone
fix: excessive cloning in runSubscribersWithMutation
2 parents fe65057 + ad95de5 commit a027eac

3 files changed

Lines changed: 287 additions & 26 deletions

File tree

sdks/typescript/packages/client/src/agent/__tests__/subscriber.test.ts

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AbstractAgent } from "../agent";
2-
import { AgentSubscriber } from "../subscriber";
2+
import { AgentSubscriber, runSubscribersWithMutation } from "../subscriber";
33
import {
44
BaseEvent,
55
EventType,
@@ -1261,6 +1261,220 @@ describe("AgentSubscriber", () => {
12611261
});
12621262
});
12631263

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+
12641478
describe("EmptyError Bug Reproduction", () => {
12651479
test("should demonstrate EmptyError with STEP_STARTED/STEP_FINISHED events that cause no mutations", async () => {
12661480
const emptyAgent = new TestAgent();

sdks/typescript/packages/client/src/agent/subscriber.ts

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ export interface AgentStateMutation {
4141
}
4242

4343
export interface AgentSubscriberParams {
44-
messages: Message[];
45-
state: State;
44+
messages: ReadonlyArray<Readonly<Message>>;
45+
// NOTE: State resolves to `any` at the type level (z.infer<typeof z.any()>), so Readonly<State>
46+
// provides no compile-time mutation protection. Runtime enforcement via deepFreeze in
47+
// dev/test mode is the only guard against in-place mutation of state.
48+
state: Readonly<State>;
4649
agent: AbstractAgent;
4750
input: RunAgentInput;
4851
}
@@ -205,41 +208,66 @@ export interface AgentSubscriber {
205208
): MaybePromise<void>;
206209
}
207210

211+
function deepFreeze<T>(obj: T): T {
212+
Object.freeze(obj);
213+
if (obj !== null && typeof obj === "object") {
214+
for (const value of Object.values(obj)) {
215+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
216+
deepFreeze(value);
217+
}
218+
}
219+
}
220+
return obj;
221+
}
222+
208223
export async function runSubscribersWithMutation(
209224
subscribers: AgentSubscriber[],
210225
initialMessages: Message[],
211226
initialState: State,
212227
executor: (
213228
subscriber: AgentSubscriber,
214-
messages: Message[],
215-
state: State,
229+
messages: ReadonlyArray<Readonly<Message>>,
230+
state: Readonly<State>,
216231
) => MaybePromise<AgentStateMutation | void>,
217232
): Promise<AgentStateMutation> {
218-
let messages: Message[] = initialMessages;
219-
let state: State = initialState;
233+
const isTestEnvironment =
234+
process.env.NODE_ENV === "test" || Boolean(process.env.VITEST_WORKER_ID);
235+
const isDev =
236+
process.env.NODE_ENV === "development" ||
237+
process.env.NODE_ENV === "test" ||
238+
Boolean(process.env.VITEST_WORKER_ID);
239+
const baselineMessages = structuredClone_(initialMessages);
240+
const baselineState = structuredClone_(initialState);
241+
let messages: Message[] = baselineMessages;
242+
let state: State = baselineState;
220243

221244
let stopPropagation: boolean | undefined = undefined;
222245

223246
for (const subscriber of subscribers) {
224247
try {
225-
const mutation = await executor(
226-
subscriber,
227-
structuredClone_(messages),
228-
structuredClone_(state),
229-
);
248+
// Subscribers receive shared references and must not mutate them in-place.
249+
// Mutations should only be communicated via the return value.
250+
// In dev/test mode only: deep-freeze inputs so accidental in-place mutations surface
251+
// as TypeErrors immediately. In production, enforcement is type-level only.
252+
if (isDev) {
253+
deepFreeze(messages);
254+
deepFreeze(state);
255+
}
256+
const mutation = await executor(subscriber, messages, state);
230257

231258
if (mutation === undefined) {
232259
// Nothing returned – keep going
233260
continue;
234261
}
235262

236-
// Merge messages/state so next subscriber sees latest view
237-
if (mutation.messages !== undefined) {
238-
messages = mutation.messages;
263+
// Replace with a defensive copy of the subscriber's mutation,
264+
// but skip if the subscriber returned the same reference (no-op).
265+
if (mutation.messages !== undefined && mutation.messages !== messages) {
266+
messages = structuredClone_(mutation.messages);
239267
}
240268

241-
if (mutation.state !== undefined) {
242-
state = mutation.state;
269+
if (mutation.state !== undefined && mutation.state !== state) {
270+
state = structuredClone_(mutation.state);
243271
}
244272

245273
stopPropagation = mutation.stopPropagation;
@@ -248,21 +276,37 @@ export async function runSubscribersWithMutation(
248276
break;
249277
}
250278
} catch (error) {
251-
// Log subscriber errors but continue processing (silence during tests)
252-
const isTestEnvironment =
253-
process.env.NODE_ENV === "test" || process.env.VITEST_WORKER_ID !== undefined;
254-
255-
if (!isTestEnvironment) {
279+
if (isDev && error instanceof TypeError) {
280+
// Likely a freeze violation: subscriber attempted to mutate frozen inputs in-place.
281+
// In test environments, re-throw so tests fail fast and the violation is visible.
282+
// In development (non-test), log a specific message to distinguish freeze violations
283+
// from ordinary subscriber errors.
284+
if (isTestEnvironment) {
285+
throw error;
286+
}
287+
console.error(
288+
"AG-UI: Subscriber attempted to mutate frozen inputs in-place. " +
289+
"Return mutations via AgentStateMutation instead of mutating directly.",
290+
error,
291+
);
292+
} else if (!isTestEnvironment) {
256293
console.error("Subscriber error:", error);
257294
}
258-
// Continue to next subscriber unless we want to stop propagation
295+
// Skip this subscriber's mutation and continue
259296
continue;
260297
}
261298
}
262299

300+
// In dev/test mode, the canonical messages/state references may have been
301+
// frozen in-place (for subscriber mutation detection). Clone them before
302+
// returning so callers receive a mutable copy, not a frozen one.
263303
return {
264-
...(JSON.stringify(messages) !== JSON.stringify(initialMessages) ? { messages } : {}),
265-
...(JSON.stringify(state) !== JSON.stringify(initialState) ? { state } : {}),
304+
...(messages !== baselineMessages
305+
? { messages: isDev && Object.isFrozen(messages) ? structuredClone_(messages) : messages }
306+
: {}),
307+
...(state !== baselineState
308+
? { state: isDev && Object.isFrozen(state) ? structuredClone_(state) : state }
309+
: {}),
266310
...(stopPropagation !== undefined ? { stopPropagation } : {}),
267311
};
268312
}

sdks/typescript/packages/client/src/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const structuredClone_ = <T>(obj: T): T => {
88
try {
99
return JSON.parse(JSON.stringify(obj));
1010
} catch (err) {
11+
// Preserve array vs object type in shallow fallback
12+
if (Array.isArray(obj)) {
13+
return [...obj] as unknown as T;
14+
}
1115
return { ...obj } as T;
1216
}
1317
};
@@ -23,7 +27,6 @@ export function randomUUID(): string {
2327
// Note: semver helpers were removed in favor of using
2428
// the external `compare-versions` library directly at call sites.
2529

26-
2730
/**
2831
* Parses a semantic version string into its numeric components.
2932
* Supports incomplete versions (e.g. "1", "1.2") by defaulting missing segments to zero.

0 commit comments

Comments
 (0)