Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 202 additions & 10 deletions packages/agent/src/adapters/claude/claude-agent.refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type SdkQueryHandle = {
interrupt: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
setMcpServers: ReturnType<typeof vi.fn>;
mcpServerStatus: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
initializationResult: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
Expand All @@ -33,6 +34,7 @@ function makeQueryHandle(): SdkQueryHandle {
interrupt: vi.fn().mockResolvedValue(undefined),
setModel: vi.fn().mockResolvedValue(undefined),
setMcpServers: vi.fn().mockResolvedValue(undefined),
mcpServerStatus: vi.fn().mockResolvedValue([]),
supportedCommands: vi.fn().mockResolvedValue([]),
initializationResult: vi.fn().mockImplementation(() => nextInitPromise),
close: vi.fn(),
Expand All @@ -55,9 +57,12 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
}));

const fetchMcpToolMetadataMock = vi.fn().mockResolvedValue(undefined);
const clearMcpToolMetadataCacheMock = vi.fn();
vi.mock("./mcp/tool-metadata", () => ({
fetchMcpToolMetadata: fetchMcpToolMetadataMock,
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
getCachedMcpTools: vi.fn().mockReturnValue([]),
clearMcpToolMetadataCache: clearMcpToolMetadataCacheMock,
}));

// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK
Expand All @@ -82,6 +87,16 @@ function installFakeSession(
const endSpy = vi.spyOn(input, "end");
const abortController = new AbortController();

// Distinguishable fresh instance per call so tests can prove a rebuild.
let freshInstanceCounter = 0;
const buildInProcessMcpServers = vi.fn(() => ({
"posthog-code-tools": {
type: "sdk" as const,
name: "posthog-code-tools",
instance: { fresh: ++freshInstanceCounter },
},
}));

const session = {
query: oldQuery,
queryOptions: {
Expand All @@ -93,11 +108,13 @@ function installFakeSession(
"posthog-code-tools": {
type: "sdk",
name: "posthog-code-tools",
instance: {},
instance: { stale: true },
},
},
abortController,
},
buildInProcessMcpServers,
localToolsServerNames: ["posthog-code-tools"],
input,
cancelled: false,
settingsManager: { dispose: vi.fn() },
Expand All @@ -123,7 +140,13 @@ function installFakeSession(
(agent as unknown as { session: unknown }).session = session;
(agent as unknown as { sessionId: string }).sessionId = sessionId;

return { session, oldQuery, endSpy, abortController };
return {
session,
oldQuery,
endSpy,
abortController,
buildInProcessMcpServers,
};
}

const freshMcpServers = [
Expand All @@ -145,6 +168,7 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => {
models: [],
});
fetchMcpToolMetadataMock.mockClear();
clearMcpToolMetadataCacheMock.mockClear();
});

it("returns methodNotFound for unknown extension methods", async () => {
Expand Down Expand Up @@ -238,7 +262,7 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => {
expect(endSpy).toHaveBeenCalledTimes(1);

// New query: resume identity (not sessionId), http server refreshed, and
// the in-process local-tools server preserved.
// the in-process local-tools server rebuilt fresh.
expect(lastQueryCall.options).toMatchObject({
resume: "s-2",
forkSession: false,
Expand Down Expand Up @@ -364,24 +388,192 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => {
expect(lastQueryCall.options?.model).toBe(expected);
});

it("preserves the in-process local-tools server across refresh", async () => {
it("rebuilds a FRESH in-process local-tools server across refresh", async () => {
const agent = makeAgent();
installFakeSession(agent, "s-inprocess");
const { session, buildInProcessMcpServers } = installFakeSession(
agent,
"s-inprocess",
);
const staleInstance = (
session as unknown as {
queryOptions: { mcpServers: Record<string, { instance?: unknown }> };
}
).queryOptions.mcpServers["posthog-code-tools"].instance;

// freshMcpServers carries only external (http) servers, so the sdk server
// must be carried over from the previous session options.
// freshMcpServers carries only external servers; the sdk server is rebuilt.
await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
mcpServers: freshMcpServers,
});

expect(buildInProcessMcpServers).toHaveBeenCalledTimes(1);
const servers = lastQueryCall.options?.mcpServers as Record<
string,
{ type?: string }
{ type?: string; name?: string; instance?: unknown }
>;
expect(servers["posthog-code-tools"]).toEqual({
expect(servers["posthog-code-tools"]).toMatchObject({
type: "sdk",
name: "posthog-code-tools",
instance: {},
});
// A brand-new instance object, never the stale reused one.
expect(servers["posthog-code-tools"].instance).not.toBe(staleInstance);
expect(servers["posthog-code-tools"].instance).toEqual({ fresh: 1 });
});

it("clears the MCP tool metadata cache on refresh", async () => {
const agent = makeAgent();
installFakeSession(agent, "s-cache");

await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
mcpServers: freshMcpServers,
});

expect(clearMcpToolMetadataCacheMock).toHaveBeenCalledTimes(1);
});
});

const DISCONNECTED_STATUS = [{ name: "posthog-code-tools", status: "failed" }];

describe("ClaudeAcpAgent self-heal: ensureLocalToolsConnected", () => {
beforeEach(() => {
clearMcpToolMetadataCacheMock.mockClear();
fetchMcpToolMetadataMock.mockClear();
});

function callHeal(agent: Agent, trigger = "test"): Promise<boolean> {
return (
agent as unknown as {
ensureLocalToolsConnected: (t: string) => Promise<boolean>;
}
).ensureLocalToolsConnected(trigger);
}

it("is a no-op when the signed-commit server is connected", async () => {
const agent = makeAgent();
const { oldQuery } = installFakeSession(agent, "s-healthy");
oldQuery.mcpServerStatus.mockResolvedValue([
{ name: "posthog-code-tools", status: "connected" },
]);

await expect(callHeal(agent)).resolves.toBe(true);
expect(oldQuery.setMcpServers).not.toHaveBeenCalled();
});

it("rebuilds and reconnects a fresh server when disconnected", async () => {
const agent = makeAgent();
const { session, oldQuery, buildInProcessMcpServers } = installFakeSession(
agent,
"s-down",
);
oldQuery.mcpServerStatus.mockResolvedValue(DISCONNECTED_STATUS);

await expect(callHeal(agent)).resolves.toBe(true);

expect(buildInProcessMcpServers).toHaveBeenCalledTimes(1);
expect(oldQuery.setMcpServers).toHaveBeenCalledTimes(1);
const arg = oldQuery.setMcpServers.mock.calls[0][0] as Record<
string,
{ type?: string; instance?: unknown }
>;
// External http server passed through unchanged; sdk server is fresh.
expect(arg.posthog).toMatchObject({ type: "http" });
expect(arg["posthog-code-tools"]).toMatchObject({ type: "sdk" });
expect(arg["posthog-code-tools"].instance).toEqual({ fresh: 1 });
expect(clearMcpToolMetadataCacheMock).toHaveBeenCalledTimes(1);
// queryOptions is updated so later heals/refresh see the fresh server set.
expect(
(session as unknown as { queryOptions: { mcpServers: unknown } })
.queryOptions.mcpServers,
).toBe(arg);
});

it("passes every external server through when reconnecting", async () => {
const agent = makeAgent();
const { session, oldQuery } = installFakeSession(agent, "s-multi");
(
session as unknown as {
queryOptions: { mcpServers: Record<string, unknown> };
}
).queryOptions.mcpServers = {
posthog: { type: "http", url: "https://old" },
sentry: { type: "sse", url: "https://sse" },
"posthog-code-tools": {
type: "sdk",
name: "posthog-code-tools",
instance: { stale: true },
},
};
oldQuery.mcpServerStatus.mockResolvedValue(DISCONNECTED_STATUS);

await expect(callHeal(agent)).resolves.toBe(true);

const arg = oldQuery.setMcpServers.mock.calls[0][0] as Record<
string,
{ type?: string }
>;
expect(Object.keys(arg).sort()).toEqual([
"posthog",
"posthog-code-tools",
"sentry",
]);
expect(arg.posthog).toMatchObject({ type: "http" });
expect(arg.sentry).toMatchObject({ type: "sse" });
expect(arg["posthog-code-tools"]).toMatchObject({ type: "sdk" });
});

it("treats a server missing from status as disconnected", async () => {
const agent = makeAgent();
const { oldQuery } = installFakeSession(agent, "s-missing");
oldQuery.mcpServerStatus.mockResolvedValue([
{ name: "some-other", status: "connected" },
]);

await expect(callHeal(agent)).resolves.toBe(true);
expect(oldQuery.setMcpServers).toHaveBeenCalledTimes(1);
});

it("does not block the turn when the status RPC fails", async () => {
const agent = makeAgent();
const { oldQuery } = installFakeSession(agent, "s-statuserr");
oldQuery.mcpServerStatus.mockRejectedValue(new Error("rpc down"));

await expect(callHeal(agent)).resolves.toBe(true);
expect(oldQuery.setMcpServers).not.toHaveBeenCalled();
});

it("does not block the turn when the status RPC hangs", async () => {
vi.useFakeTimers();
try {
const agent = makeAgent();
const { oldQuery } = installFakeSession(agent, "s-statushang");
oldQuery.mcpServerStatus.mockReturnValue(new Promise(() => {}));

const healPromise = callHeal(agent);
await vi.advanceTimersByTimeAsync(5_001);

await expect(healPromise).resolves.toBe(true);
expect(oldQuery.setMcpServers).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

it("returns false when reconnect fails", async () => {
const agent = makeAgent();
const { oldQuery } = installFakeSession(agent, "s-reconnect-fail");
oldQuery.mcpServerStatus.mockResolvedValue(DISCONNECTED_STATUS);
oldQuery.setMcpServers.mockRejectedValue(new Error("connect boom"));

await expect(callHeal(agent)).resolves.toBe(false);
});

it("is a no-op when no in-process server is enabled", async () => {
const agent = makeAgent();
const { session, oldQuery } = installFakeSession(agent, "s-none");
(
session as unknown as { localToolsServerNames: string[] }
).localToolsServerNames = [];

await expect(callHeal(agent)).resolves.toBe(true);
expect(oldQuery.mcpServerStatus).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ function installFakeSession(
const session = {
query,
queryOptions: { sessionId, cwd: "/tmp/repo", abortController },
buildInProcessMcpServers: () => ({}),
localToolsServerNames: [] as string[],
input,
cancelled: false,
interruptReason: undefined,
Expand Down
Loading
Loading