import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// #781: concurrent siblings on the agent-sdk provider used to bail out
// empty because the recursion guard mutated process.env synchronously
// before the first await. With the guard scoped to AsyncLocalStorage,
// each sibling runs in its own context and receives the real SDK result.
// vi.mock is hoisted above module-scope `const`/`let`, so the factory's
// closure can't safely reference non-hoisted bindings. Use vi.hoisted to
// declare the mock's mutable state alongside the mock itself.
const state = vi.hoisted(() => ({
queryCalls: [] as Array<{ systemPrompt: string; userPrompt: string }>,
mockResult: "ok" as
| string
| ((systemPrompt: string, userPrompt: string) => string),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: ({
prompt,
options,
}: {
prompt: string;
options: { systemPrompt: string };
}) => {
state.queryCalls.push({ systemPrompt: options.systemPrompt, userPrompt: prompt });
async function* gen() {
const value =
typeof state.mockResult === "function"
? await state.mockResult(options.systemPrompt, prompt)
: state.mockResult;
yield { type: "result", result: value } as { type: "result"; result: string };
}
return gen();
},
}));
import { AgentSDKProvider } from "../src/providers/agent-sdk.js";
describe("AgentSDKProvider recursion guard (#781)", () => {
beforeEach(() => {
state.queryCalls.length = 0;
state.mockResult = "ok";
delete process.env.AGENTMEMORY_SDK_CHILD;
});
afterEach(() => {
delete process.env.AGENTMEMORY_SDK_CHILD;
});
it("concurrent summarize calls each return the SDK result (no empty siblings)", async () => {
const provider = new AgentSDKProvider();
const results = await Promise.all([
provider.summarize("sys", "chunk 1"),
provider.summarize("sys", "chunk 2"),
provider.summarize("sys", "chunk 3"),
provider.summarize("sys", "chunk 4"),
]);
expect(results).toEqual([
"ok",
"ok",
"ok",
"ok",
]);
expect(state.queryCalls.length).toBe(4);
expect(state.queryCalls.map((c) => c.userPrompt)).toEqual([
"chunk 1",
"chunk 2",
"chunk 3",
"chunk 4",
]);
});
it("compress and summarize share the same guard scope without interfering", async () => {
const provider = new AgentSDKProvider();
const [a, b, c] = await Promise.all([
provider.summarize("sys", "s1"),
provider.compress("sys", "c1"),
provider.summarize("sys", "s2"),
]);
expect(a).toBe("ok");
expect(b).toBe("ok");
expect(c).toBe("ok");
expect(state.queryCalls.length).toBe(3);
});
it("sets AGENTMEMORY_SDK_CHILD=1 while inside the SDK call (so spawned subprocesses inherit it)", async () => {
const provider = new AgentSDKProvider();
let observedEnv: string | undefined;
state.mockResult = (sysPrompt, _userPrompt) => {
observedEnv = process.env.AGENTMEMORY_SDK_CHILD;
return `${sysPrompt}`;
};
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
await provider.summarize("sys", "user");
expect(observedEnv).toBe("1");
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
});
it("restores AGENTMEMORY_SDK_CHILD to its prior value after the call", async () => {
const provider = new AgentSDKProvider();
process.env.AGENTMEMORY_SDK_CHILD = "prev-value";
await provider.summarize("sys", "user");
expect(process.env.AGENTMEMORY_SDK_CHILD).toBe("prev-value");
});
it("keeps AGENTMEMORY_SDK_CHILD=1 for the full overlap of concurrent calls", async () => {
const provider = new AgentSDKProvider();
// Allow the calls to overlap: each call records the env value it
// saw, then a tick later records it again. With a refcounted guard
// both observations on both calls should see "1"; with the old
// per-call snapshot one call's restore would null the env while
// the sibling is still mid-flight.
const observations: Array<{ id: string; phase: string; env: string | undefined }> = [];
state.mockResult = async (sysPrompt, _user) => {
observations.push({ id: sysPrompt, phase: "enter", env: process.env.AGENTMEMORY_SDK_CHILD });
await new Promise((resolve) => setTimeout(resolve, 5));
observations.push({ id: sysPrompt, phase: "exit", env: process.env.AGENTMEMORY_SDK_CHILD });
return `${sysPrompt}`;
};
await Promise.all([
provider.summarize("a", "x"),
provider.summarize("b", "y"),
provider.summarize("c", "z"),
]);
expect(observations.length).toBe(6);
for (const o of observations) {
expect(o.env).toBe("1");
}
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
});
it("genuine re-entry (an inner call inside the same async tree) still degrades to empty", async () => {
const provider = new AgentSDKProvider();
let innerResult = "not-set";
state.mockResult = async (_sys, _user) => {
// Simulate the SDK callback re-entering the provider while the
// outer call is still active. The ALS frame is active here, so
// the inner call must return "" to break the recursion.
innerResult = await provider.summarize("sys-inner", "user-inner");
return "outer";
};
const outer = await provider.summarize("sys", "user");
expect(outer).toBe("outer");
expect(innerResult).toBe("");
});
});