fix: Recreate resumed sessions when params change (#515)

Closes #512

Fingerprint session-defining params to detect cwd or MCP server
changes, reuse sessions when unchanged, and ignore MCP server
ordering differences. Extract session teardown into a helper and add
tests covering reuse and recreation paths.
This commit is contained in:
Ben Brandt
2026-04-06 22:44:14 +02:00
committed by GitHub
parent 26f3e8a521
commit aa82193330
2 changed files with 182 additions and 12 deletions

View File

@@ -114,6 +114,9 @@ type Session = {
input: Pushable<SDKUserMessage>;
cancelled: boolean;
cwd: string;
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
* detect when loadSession/resumeSession is called with changed values. */
sessionFingerprint: string;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
modes: SessionModeState;
@@ -125,6 +128,18 @@ type Session = {
abortController: AbortController;
};
/** Compute a stable fingerprint of the session-defining params so we can
* detect when a loadSession/resumeSession call requires tearing down and
* recreating the underlying Query process. MCP servers are sorted by name
* so that ordering differences don't trigger unnecessary recreations. */
function computeSessionFingerprint(params: {
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
}): string {
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
}
type BackgroundTerminal =
| {
handle: TerminalHandle;
@@ -901,17 +916,24 @@ export class ClaudeAcpAgent implements Agent {
await session.query.interrupt();
}
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessions[params.sessionId];
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
* and remove it from the session map. */
private async teardownSession(sessionId: string): Promise<void> {
const session = this.sessions[sessionId];
if (!session) {
throw new Error("Session not found");
return;
}
await this.cancel({ sessionId: params.sessionId });
await this.cancel({ sessionId });
session.settingsManager.dispose();
session.abortController.abort();
delete this.sessions[params.sessionId];
delete this.sessions[sessionId];
}
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
await this.teardownSession(params.sessionId);
return {};
}
@@ -1260,12 +1282,20 @@ export class ClaudeAcpAgent implements Agent {
}): Promise<NewSessionResponse> {
const existingSession = this.sessions[params.sessionId];
if (existingSession) {
return {
sessionId: params.sessionId,
modes: existingSession.modes,
models: existingSession.models,
configOptions: existingSession.configOptions,
};
const fingerprint = computeSessionFingerprint(params);
if (fingerprint === existingSession.sessionFingerprint) {
return {
sessionId: params.sessionId,
modes: existingSession.modes,
models: existingSession.models,
configOptions: existingSession.configOptions,
};
}
// Session-defining params changed (e.g. cwd pointed at a git worktree,
// or MCP servers reconfigured). Tear down the existing session and
// recreate it so the underlying Query process picks up the new values.
await this.teardownSession(params.sessionId);
}
const response = await this.createSession(
@@ -1531,6 +1561,7 @@ export class ClaudeAcpAgent implements Agent {
input: input,
cancelled: false,
cwd: params.cwd,
sessionFingerprint: computeSessionFingerprint(params),
settingsManager,
accumulatedUsage: {
inputTokens: 0,

View File

@@ -1328,6 +1328,7 @@ describe("stop reason propagation", () => {
input,
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: {
currentModeId: "default",
availableModes: [],
@@ -1466,6 +1467,7 @@ describe("stop reason propagation", () => {
query: messageGenerator() as any,
input,
cwd: "/tmp/test",
sessionFingerprint: JSON.stringify({ cwd: "/tmp/test", mcpServers: [] }),
cancelled: false,
modes: {
currentModeId: "default",
@@ -1540,6 +1542,7 @@ describe("session/close", () => {
input: new Pushable(),
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: {
currentModeId: "default",
availableModes: [],
@@ -1609,6 +1612,141 @@ describe("session/close", () => {
});
});
describe("getOrCreateSession param change detection", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
function injectSession(
agent: ClaudeAcpAgent,
sessionId: string,
opts: { cwd?: string; mcpServers?: { name: string }[] } = {},
) {
const cwd = opts.cwd ?? "/test";
const mcpServers = (opts.mcpServers ?? []) as any[];
function* empty() {}
const gen = Object.assign(empty(), {
interrupt: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
});
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd,
sessionFingerprint: JSON.stringify({
cwd,
mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)),
}),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
return agent.sessions[sessionId]!;
}
it("returns cached session when params are unchanged", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "s1", { cwd: "/project" });
await agent.unstable_resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [],
});
// Session object should be the exact same reference (not recreated)
expect(agent.sessions["s1"]).toBe(session);
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
});
it("tears down existing session when cwd changes", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "s1", { cwd: "/old" });
// Mock createSession to avoid spawning a real process.
// It will throw, but we can catch that — we only need to verify
// the old session was torn down before createSession was attempted.
const createSessionSpy = vi
.spyOn(agent as any, "createSession")
.mockRejectedValue(new Error("mock"));
await expect(
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
).rejects.toThrow("mock");
// Old session should have been fully torn down
expect(session.settingsManager.dispose).toHaveBeenCalled();
expect(session.abortController.signal.aborted).toBe(true);
expect(session.query.interrupt).toHaveBeenCalled();
expect(agent.sessions["s1"]).toBeUndefined();
// createSession should have been called with the new cwd
expect(createSessionSpy).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/new" }),
expect.objectContaining({ resume: "s1" }),
);
});
it("tears down existing session when mcpServers change", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "s1", { cwd: "/project" });
const createSessionSpy = vi
.spyOn(agent as any, "createSession")
.mockRejectedValue(new Error("mock"));
await expect(
agent.unstable_resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
}),
).rejects.toThrow("mock");
expect(session.settingsManager.dispose).toHaveBeenCalled();
expect(session.abortController.signal.aborted).toBe(true);
expect(agent.sessions["s1"]).toBeUndefined();
expect(createSessionSpy).toHaveBeenCalled();
});
it("treats mcpServers in different order as unchanged", async () => {
const agent = createMockAgent();
const servers = [
{ name: "b-server", command: "node", args: ["b.js"], env: [] },
{ name: "a-server", command: "node", args: ["a.js"], env: [] },
] as const;
const session = injectSession(agent, "s1", {
cwd: "/project",
mcpServers: servers as any,
});
// Same servers but reversed order — should NOT trigger teardown
await agent.unstable_resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [...servers].reverse() as any,
});
expect(agent.sessions["s1"]).toBe(session);
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
});
});
describe("usage_update computation", () => {
function createAssistantMessage(overrides: {
model: string;
@@ -1710,6 +1848,7 @@ describe("usage_update computation", () => {
input,
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: {
currentModeId: "default",
availableModes: [],