diff --git a/.changeset/support-acp-multi-root.md b/.changeset/support-acp-multi-root.md new file mode 100644 index 000000000..9b06d3856 --- /dev/null +++ b/.changeset/support-acp-multi-root.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Support ACP multi-root workspaces when creating, loading, and resuming sessions. diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a43fe5ebb..cbbe1758a 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -227,6 +227,7 @@ export class AcpServer implements Agent { sse: true, }, sessionCapabilities: { + additionalDirectories: {}, list: {}, resume: {}, }, @@ -286,6 +287,7 @@ export class AcpServer implements Agent { const session = await this.harness.createSession({ id: sessionId, workDir: params.cwd, + additionalDirs: params.additionalDirectories, kaos: acpKaos, persistenceKaos, sessionStartedProperties: { mode: 'new' }, @@ -358,6 +360,7 @@ export class AcpServer implements Agent { const { session, acpSession, configOptions } = await this.setupSessionFromExisting({ cwd: params.cwd, sessionId: params.sessionId, + additionalDirectories: params.additionalDirectories, mcpServers: params.mcpServers, mode: 'load', }); @@ -394,6 +397,7 @@ export class AcpServer implements Agent { const { session, configOptions } = await this.setupSessionFromExisting({ cwd: params.cwd, sessionId: params.sessionId, + additionalDirectories: params.additionalDirectories, mcpServers: params.mcpServers, mode: 'resume', }); @@ -427,6 +431,7 @@ export class AcpServer implements Agent { private async setupSessionFromExisting(params: { cwd: string; sessionId: string; + additionalDirectories?: readonly string[]; mcpServers?: ReadonlyArray; mode: 'load' | 'resume'; }): Promise<{ @@ -456,6 +461,7 @@ export class AcpServer implements Agent { try { session = await this.harness.resumeSession({ id: params.sessionId, + additionalDirs: params.additionalDirectories, kaos: acpKaos, persistenceKaos, sessionStartedProperties: { mode: params.mode }, diff --git a/packages/acp-adapter/test/e2e-happy-path.test.ts b/packages/acp-adapter/test/e2e-happy-path.test.ts index 8ee7c56da..f62641eda 100644 --- a/packages/acp-adapter/test/e2e-happy-path.test.ts +++ b/packages/acp-adapter/test/e2e-happy-path.test.ts @@ -174,6 +174,7 @@ describe('AcpServer end-to-end happy path', () => { sse: true, }, sessionCapabilities: { + additionalDirectories: {}, list: {}, resume: {}, }, diff --git a/packages/acp-adapter/test/server.test.ts b/packages/acp-adapter/test/server.test.ts index 883fae3d3..d7e6e527f 100644 --- a/packages/acp-adapter/test/server.test.ts +++ b/packages/acp-adapter/test/server.test.ts @@ -79,6 +79,7 @@ describe('AcpServer + AgentSideConnection', () => { expect(response.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true); expect(response.agentCapabilities?.mcpCapabilities?.http).toBe(true); expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(true); + expect(response.agentCapabilities?.sessionCapabilities?.additionalDirectories).toEqual({}); expect(response.agentCapabilities?.sessionCapabilities?.list).toEqual({}); expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({}); }); diff --git a/packages/acp-adapter/test/session-load.test.ts b/packages/acp-adapter/test/session-load.test.ts index fb3e62807..18dc4710a 100644 --- a/packages/acp-adapter/test/session-load.test.ts +++ b/packages/acp-adapter/test/session-load.test.ts @@ -82,6 +82,7 @@ function makeSessionWithHistory( function makeHarness( opts: { + capturedResumeInputs?: Array<{ additionalDirs?: readonly string[]; id: string }>; hasUsableToken?: boolean; session?: Session; resumeError?: Error; @@ -92,7 +93,8 @@ function makeHarness( auth: { status: async () => (authed ? AUTHED_STATUS : UNAUTHED_STATUS), }, - resumeSession: async (_input: { id: string }) => { + resumeSession: async (input: { additionalDirs?: readonly string[]; id: string }) => { + opts.capturedResumeInputs?.push(input); if (opts.resumeError) throw opts.resumeError; if (!opts.session) throw new Error('test harness has no session configured'); return opts.session; @@ -129,6 +131,30 @@ describe('AcpServer session/load auth gate', () => { }); describe('AcpServer session/load replay', () => { + it('passes ACP additionalDirectories through as SDK additionalDirs', async () => { + const sessionId = 'sess-load-multi-root'; + const session = makeSessionWithHistory(sessionId, []); + const capturedResumeInputs: Array<{ additionalDirs?: readonly string[]; id: string }> = []; + const harness = makeHarness({ hasUsableToken: true, session, capturedResumeInputs }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await clientConn.loadSession({ + sessionId, + cwd: '/tmp/x', + additionalDirectories: ['/tmp/docs', '/tmp/plugin'], + mcpServers: [], + }); + + expect(capturedResumeInputs).toHaveLength(1); + expect(capturedResumeInputs[0]).toMatchObject({ + id: sessionId, + additionalDirs: ['/tmp/docs', '/tmp/plugin'], + }); + }); + it('replays a single assistant text-only turn as agent_message_chunk updates', async () => { const sessionId = 'sess-text-only'; const history = [ diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index ece351999..a82776e8f 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -46,7 +46,12 @@ function makeInMemoryStreamPair(): { } interface CapturedCall { - options: { id?: string; workDir: string; mcpServers?: Record }; + options: { + additionalDirs?: readonly string[]; + id?: string; + mcpServers?: Record; + workDir: string; + }; } function makeHarness(sessionId: string, captured: CapturedCall[]): { @@ -61,7 +66,11 @@ function makeHarness(sessionId: string, captured: CapturedCall[]): { } as unknown as Session; const harness = { auth: { status: async () => AUTHED_STATUS }, - createSession: async (options: { id?: string; workDir: string }) => { + createSession: async (options: { + additionalDirs?: readonly string[]; + id?: string; + workDir: string; + }) => { captured.push({ options }); return Object.assign({}, fakeSession, { id: options.id ?? sessionId }) as Session; }, @@ -114,7 +123,11 @@ describe('AcpServer session/new', () => { const captured: CapturedCall[] = []; const harness = { auth: { status: async () => AUTHED_STATUS }, - createSession: async (options: { id?: string; workDir: string }) => { + createSession: async (options: { + additionalDirs?: readonly string[]; + id?: string; + workDir: string; + }) => { captured.push({ options }); return { id: options.id ?? 'fallback', @@ -144,6 +157,24 @@ describe('AcpServer session/new', () => { expect(captured[1]?.options.id).toBe(second.sessionId); }); + it('passes ACP additionalDirectories through as SDK additionalDirs', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-multi-root', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ + cwd: '/tmp/work', + additionalDirectories: ['/tmp/docs', '/tmp/plugin'], + mcpServers: [], + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.options.additionalDirs).toEqual(['/tmp/docs', '/tmp/plugin']); + }); + it('advertises configOptions (PLAN D11 + Phase 15 thinking toggle) — model + thinking + mode under the unified SessionConfigOption surface', async () => { const captured: CapturedCall[] = []; const { harness } = makeHarness('sess-modes', captured); diff --git a/packages/acp-adapter/test/session-resume.test.ts b/packages/acp-adapter/test/session-resume.test.ts index 2b4640ece..b05eac723 100644 --- a/packages/acp-adapter/test/session-resume.test.ts +++ b/packages/acp-adapter/test/session-resume.test.ts @@ -98,6 +98,7 @@ function makeSessionWithMainConfig( } function makeHarness(opts: { + capturedResumeInputs?: Array<{ additionalDirs?: readonly string[]; id: string }>; hasUsableToken?: boolean; session?: Session; resumeError?: Error; @@ -107,7 +108,8 @@ function makeHarness(opts: { auth: { status: async () => (authed ? AUTHED_STATUS : UNAUTHED_STATUS), }, - resumeSession: async (_input: { id: string }) => { + resumeSession: async (input: { additionalDirs?: readonly string[]; id: string }) => { + opts.capturedResumeInputs?.push(input); if (opts.resumeError) throw opts.resumeError; if (!opts.session) throw new Error('test harness has no session configured'); return opts.session; @@ -139,6 +141,30 @@ describe('AcpServer.resumeSession', () => { ).rejects.toMatchObject({ code: -32000 }); }); + it('passes ACP additionalDirectories through as SDK additionalDirs', async () => { + const sessionId = 'sess-resume-multi-root'; + const session = makeSessionWithMainConfig(sessionId); + const capturedResumeInputs: Array<{ additionalDirs?: readonly string[]; id: string }> = []; + const harness = makeHarness({ hasUsableToken: true, session, capturedResumeInputs }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await clientConn.resumeSession({ + sessionId, + cwd: '/tmp/x', + additionalDirectories: ['/tmp/docs', '/tmp/plugin'], + mcpServers: [], + }); + + expect(capturedResumeInputs).toHaveLength(1); + expect(capturedResumeInputs[0]).toMatchObject({ + id: sessionId, + additionalDirs: ['/tmp/docs', '/tmp/plugin'], + }); + }); + it('returns configOptions matching the resumed session model + mode + thinking', async () => { const sessionId = 'sess-resume-model'; // Resume state reports kimi-plain (thinking unsupported) so we can diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d503143ea..328b302a9 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -120,6 +120,8 @@ export class KimiHarness { if (active !== undefined) { if (kaos !== undefined || persistenceKaos !== undefined) { await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + } else if (resumeInput.additionalDirs !== undefined) { + await this.rpc.resumeSession({ ...resumeInput, id }); } return active; } diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 5bd3d4a64..3be850c25 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -52,6 +52,7 @@ max_context_size = 1000 } class StubRpc extends SDKRpcClientBase { + plainResumeCalls: ResumeSessionInput[] = []; resumeCalls: Array<{ input: ResumeSessionInput; kaos: Kaos; persistenceKaos?: Kaos }> = []; protected async getRpc(): Promise { @@ -68,6 +69,27 @@ class StubRpc extends SDKRpcClientBase { }; } + override async resumeSession(input: ResumeSessionInput): Promise { + this.plainResumeCalls.push(input); + return { + id: input.id, + workDir: '/tmp/work', + sessionDir: '/tmp/session', + createdAt: 1, + updatedAt: 1, + additionalDirs: input.additionalDirs ?? [], + sessionMetadata: { + createdAt: '', + updatedAt: '', + title: '', + isCustomTitle: false, + agents: {}, + custom: {}, + }, + agents: {}, + }; + } + override async resumeSessionWithKaos(input: ResumeSessionInput, kaos: Kaos, persistenceKaos?: Kaos): Promise { this.resumeCalls.push({ input, kaos, persistenceKaos }); return { @@ -76,6 +98,7 @@ class StubRpc extends SDKRpcClientBase { sessionDir: '/tmp/session', createdAt: 1, updatedAt: 1, + additionalDirs: input.additionalDirs ?? [], sessionMetadata: { createdAt: '', updatedAt: '', @@ -694,6 +717,35 @@ effort = "medium" persistenceKaos: undefined, }); }); + + it('refreshes an active session when resumeSession receives additionalDirs without a new Kaos', async () => { + const records: TelemetryRecord[] = []; + const rpc = new StubRpc(); + const harness = new KimiHarness(rpc, { + homeDir: '/tmp/home', + configPath: '/tmp/config.toml', + auth: { status: async () => ({ providers: [] }) } as never, + telemetry: recordingTelemetry(records), + ensureConfigFile: async () => undefined, + onClose: () => undefined, + }); + + const session = await harness.createSession({ id: 'ses_active_dirs', workDir: '/tmp/work' }); + + const resumed = await harness.resumeSession({ + id: session.id, + additionalDirs: ['/tmp/extra'], + }); + + expect(resumed).toBe(session); + expect(rpc.plainResumeCalls).toEqual([ + { + id: 'ses_active_dirs', + additionalDirs: ['/tmp/extra'], + }, + ]); + expect(rpc.resumeCalls).toHaveLength(0); + }); }); function coreSessionIds(harness: KimiHarness): readonly string[] {