mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user