Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/support-acp-multi-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Support ACP multi-root workspaces when creating, loading, and resuming sessions.
6 changes: 6 additions & 0 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export class AcpServer implements Agent {
sse: true,
},
sessionCapabilities: {
additionalDirectories: {},
list: {},
resume: {},
},
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -427,6 +431,7 @@ export class AcpServer implements Agent {
private async setupSessionFromExisting(params: {
cwd: string;
sessionId: string;
additionalDirectories?: readonly string[];
mcpServers?: ReadonlyArray<McpServer>;
mode: 'load' | 'resume';
}): Promise<{
Expand Down Expand Up @@ -456,6 +461,7 @@ export class AcpServer implements Agent {
try {
session = await this.harness.resumeSession({
id: params.sessionId,
additionalDirs: params.additionalDirectories,
Comment thread
faga295 marked this conversation as resolved.
kaos: acpKaos,
persistenceKaos,
sessionStartedProperties: { mode: params.mode },
Expand Down
1 change: 1 addition & 0 deletions packages/acp-adapter/test/e2e-happy-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe('AcpServer end-to-end happy path', () => {
sse: true,
},
sessionCapabilities: {
additionalDirectories: {},
list: {},
resume: {},
},
Expand Down
1 change: 1 addition & 0 deletions packages/acp-adapter/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
Expand Down
28 changes: 27 additions & 1 deletion packages/acp-adapter/test/session-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function makeSessionWithHistory(

function makeHarness(
opts: {
capturedResumeInputs?: Array<{ additionalDirs?: readonly string[]; id: string }>;
hasUsableToken?: boolean;
session?: Session;
resumeError?: Error;
Expand All @@ -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;
Expand Down Expand Up @@ -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 = [
Expand Down
37 changes: 34 additions & 3 deletions packages/acp-adapter/test/session-new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ function makeInMemoryStreamPair(): {
}

interface CapturedCall {
options: { id?: string; workDir: string; mcpServers?: Record<string, unknown> };
options: {
additionalDirs?: readonly string[];
id?: string;
mcpServers?: Record<string, unknown>;
workDir: string;
};
}

function makeHarness(sessionId: string, captured: CapturedCall[]): {
Expand All @@ -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;
},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 27 additions & 1 deletion packages/acp-adapter/test/session-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function makeSessionWithMainConfig(
}

function makeHarness(opts: {
capturedResumeInputs?: Array<{ additionalDirs?: readonly string[]; id: string }>;
hasUsableToken?: boolean;
session?: Session;
resumeError?: Error;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/node-sdk/src/kimi-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
52 changes: 52 additions & 0 deletions packages/node-sdk/test/create-session-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
Expand All @@ -68,6 +69,27 @@ class StubRpc extends SDKRpcClientBase {
};
}

override async resumeSession(input: ResumeSessionInput): Promise<ResumedSessionSummary> {
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<ResumedSessionSummary> {
this.resumeCalls.push({ input, kaos, persistenceKaos });
return {
Expand All @@ -76,6 +98,7 @@ class StubRpc extends SDKRpcClientBase {
sessionDir: '/tmp/session',
createdAt: 1,
updatedAt: 1,
additionalDirs: input.additionalDirs ?? [],
sessionMetadata: {
createdAt: '',
updatedAt: '',
Expand Down Expand Up @@ -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[] {
Expand Down