From 836b787a6afa81ddad469e1bdd4d597519cae48c Mon Sep 17 00:00:00 2001 From: ranzhenyu <411285752@qq.com> Date: Tue, 4 Aug 2026 00:44:42 +0800 Subject: [PATCH 1/2] fix(session): recreate query when configured skills change Why: Resumed sessions reused a Query created with stale skills. What: Normalize and fingerprint skills while preserving SDK semantics; add lifecycle regressions. Impact: Skill changes now recreate the Query under the same session ID without fingerprinting unrelated options. Refs: #955 --- src/acp-agent.ts | 31 ++++++++++--- src/tests/acp-agent.test.ts | 93 ++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 7c95bf93..7cf8be6e 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -461,8 +461,8 @@ type Session = { * without ever reaching the model). */ queryClosed?: boolean; cwd: string; - /** Serialized snapshot of session-defining params (cwd, mcpServers) used to - * detect when loadSession/resumeSession is called with changed values. */ + /** Serialized snapshot of session-defining params (cwd, mcpServers, skills) + * used to detect when loadSession/resumeSession is called with changed values. */ sessionFingerprint: string; settingsManager: SettingsManager; accumulatedUsage: AccumulatedUsage; @@ -738,16 +738,32 @@ function disarmForceCancel(session: Session): void { } } +/** Normalize skills without changing their SDK semantics. Array order and + * duplicates do not affect the selected skill set, so neither should rebuild + * the underlying Query process. */ +function normalizeSkills(skills: Options["skills"]): Options["skills"] { + return Array.isArray(skills) ? [...new Set(skills)].sort() : skills; +} + /** 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. */ + * recreating the underlying Query process. MCP servers are sorted by name, + * and skills are normalized as a set, so ordering differences don't trigger + * unnecessary recreations. */ function computeSessionFingerprint(params: { cwd: string; mcpServers?: NewSessionRequest["mcpServers"]; + _meta?: NewSessionRequest["_meta"]; }): string { const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name)); - return JSON.stringify({ cwd: params.cwd, mcpServers: servers }); + const skills = normalizeSkills( + (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.skills, + ); + return JSON.stringify({ + cwd: params.cwd, + mcpServers: servers, + ...(skills !== undefined && { skills }), + }); } export type SDKMessageFilter = { @@ -5446,8 +5462,9 @@ export class ClaudeAcpAgent { } // 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. + // MCP servers reconfigured, or the skill set changed). Tear down the + // existing session and recreate it so the underlying Query process picks + // up the new values. await this.teardownSession(params.sessionId); } diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index b70c5e93..f69ec83e 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -52,6 +52,7 @@ import { getSessionMessages, query, SDKAssistantMessage, + type Options, } from "@anthropic-ai/claude-agent-sdk"; import { randomUUID } from "crypto"; import { GOAL_CONTROL_METHOD, parseGoalRequest, toGoalSnapshot } from "../goal-extension.js"; @@ -4329,10 +4330,15 @@ describe("getOrCreateSession param change detection", () => { function injectSession( agent: ClaudeAcpAgent, sessionId: string, - opts: { cwd?: string; mcpServers?: { name: string }[] } = {}, + opts: { + cwd?: string; + mcpServers?: { name: string }[]; + skills?: Options["skills"]; + } = {}, ) { const cwd = opts.cwd ?? "/test"; const mcpServers = (opts.mcpServers ?? []) as any[]; + const skills = Array.isArray(opts.skills) ? [...new Set(opts.skills)].sort() : opts.skills; function* empty() {} const gen = Object.assign(empty(), { interrupt: vi.fn(), @@ -4347,6 +4353,7 @@ describe("getOrCreateSession param change detection", () => { sessionFingerprint: JSON.stringify({ cwd, mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)), + ...(skills !== undefined && { skills }), }), modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, @@ -4465,6 +4472,90 @@ describe("getOrCreateSession param change detection", () => { expect(agent.sessions["s1"]).toBe(session); expect(session.settingsManager.dispose).not.toHaveBeenCalled(); }); + + it.each<{ + label: string; + previousSkills: Options["skills"]; + nextSkills: Options["skills"]; + }>([ + { label: "CLI defaults to all skills", previousSkills: undefined, nextSkills: "all" }, + { label: "all skills to no skills", previousSkills: "all", nextSkills: [] }, + { label: "no skills to CLI defaults", previousSkills: [], nextSkills: undefined }, + { label: "one explicit skill list to another", previousSkills: ["pdf"], nextSkills: ["docx"] }, + ])( + "tears down the existing session when skills change from $label", + async ({ previousSkills, nextSkills }) => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { + cwd: "/project", + skills: previousSkills, + }); + const meta = + nextSkills === undefined ? undefined : { claudeCode: { options: { skills: nextSkills } } }; + const createSessionSpy = vi + .spyOn(agent as any, "createSession") + .mockRejectedValue(new Error("mock")); + + await expect( + agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [], + _meta: meta, + }), + ).rejects.toThrow("mock"); + + expect(session.settingsManager.dispose).toHaveBeenCalled(); + expect(session.abortController.signal.aborted).toBe(true); + expect(session.query.interrupt).toHaveBeenCalled(); + expect(agent.sessions["s1"]).toBeUndefined(); + expect(createSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({ _meta: meta }), + expect.objectContaining({ resume: "s1" }), + ); + }, + ); + + it("treats reordered and duplicate skills as unchanged", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { + cwd: "/project", + skills: ["pdf", "docx"], + }); + + await agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [], + _meta: { + claudeCode: { + options: { skills: ["pdf", "docx", "pdf"] }, + }, + }, + }); + + expect(agent.sessions["s1"]).toBe(session); + expect(session.settingsManager.dispose).not.toHaveBeenCalled(); + }); + + it("ignores unrelated Claude options when computing the fingerprint", async () => { + const agent = createMockAgent(); + const session = injectSession(agent, "s1", { cwd: "/project" }); + + await agent.resumeSession({ + sessionId: "s1", + cwd: "/project", + mcpServers: [], + _meta: { + claudeCode: { + options: { env: { CUSTOM_ENV: "changed" } }, + }, + }, + }); + + expect(agent.sessions["s1"]).toBe(session); + expect(session.settingsManager.dispose).not.toHaveBeenCalled(); + }); }); describe("usage_update computation", () => { From 3125dc54b69eaaff3fa81e31a2a259a1ca53ac74 Mon Sep 17 00:00:00 2001 From: ranzhenyu <411285752@qq.com> Date: Tue, 4 Aug 2026 08:00:23 +0800 Subject: [PATCH 2/2] test(session): cover successful skills query recreation Why: The lifecycle regression only proved teardown and a recreation attempt. What: Exercise newSession through resumeSession and assert the SDK receives the replacement skills. Impact: The PR now covers a successful Query replacement while preserving the native session ID. Refs: #955 --- src/tests/create-session-options.test.ts | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index bad683c6..49bbfbc4 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -18,6 +18,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { query: (args: { prompt: unknown; options: Options }) => { capturedOptions = args.options; return makeMockQuery({ + interrupt: async () => undefined, + close: () => {}, initializationResult: async () => ({ models: [ { @@ -263,6 +265,36 @@ describe("createSession options merging", () => { expect(capturedOptions!.tools).toEqual([]); }); + it("recreates a resumed Query with changed skills and the same session ID", async () => { + const created = await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { + claudeCode: { + options: { skills: ["pdf"] }, + }, + }, + }); + const initialQuery = agent.sessions[created.sessionId]!.query; + expect(capturedOptions!.skills).toEqual(["pdf"]); + + await agent.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [], + _meta: { + claudeCode: { + options: { skills: ["docx"] }, + }, + }, + }); + + expect(Object.keys(agent.sessions)).toEqual([created.sessionId]); + expect(agent.sessions[created.sessionId]!.query).not.toBe(initialQuery); + expect(capturedOptions!.resume).toBe(created.sessionId); + expect(capturedOptions!.skills).toEqual(["docx"]); + }); + describe("subagent transcript forwarding", () => { it("keeps the legacy default when neither the client nor caller opts in", async () => { await agent.newSession({ cwd: process.cwd(), mcpServers: [] });