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/fix-acp-auth-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix ACP session load and resume returning parameter errors before authentication errors on malformed directories.
5 changes: 5 additions & 0 deletions .changeset/refresh-sdk-resume-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
---

Apply additional session root updates and refresh active resumed session summaries.
5 changes: 5 additions & 0 deletions .changeset/support-acp-additional-directories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/acp-adapter": patch
---

Advertise and forward ACP additionalDirectories when creating, loading, and resuming sessions.
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.
52 changes: 52 additions & 0 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { Readable, Writable } from 'node:stream';
import { randomUUID } from 'node:crypto';
import path from 'node:path';

import {
AgentSideConnection,
Expand Down Expand Up @@ -227,6 +228,7 @@ export class AcpServer implements Agent {
sse: true,
},
sessionCapabilities: {
additionalDirectories: {},
list: {},
resume: {},
},
Expand Down Expand Up @@ -281,13 +283,15 @@ export class AcpServer implements Agent {
// by the kernel `SessionImpl` ctor and every tool downstream sees
// the same reference, no AsyncLocalStorage needed.
const sessionId = `session_${randomUUID()}`;
const additionalDirs = validateAdditionalDirectories(params.additionalDirectories);
const acpKaos = await this.maybeBuildAcpKaos(sessionId);
const persistenceKaos = acpKaos === undefined ? undefined : await this.ensureInnerKaos();
const session = await this.harness.createSession({
id: sessionId,
workDir: params.cwd,
kaos: acpKaos,
persistenceKaos,
additionalDirs,
sessionStartedProperties: { mode: 'new' },
// @ts-expect-error — `mcpServers` is a kernel-side extension
// (agent-core `CreateSessionPayload`) the SDK transparently
Expand Down Expand Up @@ -359,6 +363,7 @@ export class AcpServer implements Agent {
cwd: params.cwd,
sessionId: params.sessionId,
mcpServers: params.mcpServers,
additionalDirectories: params.additionalDirectories,
mode: 'load',
});
// Synchronously replay history — the response must not settle
Expand Down Expand Up @@ -395,6 +400,7 @@ export class AcpServer implements Agent {
cwd: params.cwd,
sessionId: params.sessionId,
mcpServers: params.mcpServers,
additionalDirectories: params.additionalDirectories,
mode: 'resume',
});
this.scheduleAvailableCommandsUpdate(session.id);
Expand Down Expand Up @@ -428,6 +434,7 @@ export class AcpServer implements Agent {
cwd: string;
sessionId: string;
mcpServers?: ReadonlyArray<McpServer>;
additionalDirectories?: unknown;
mode: 'load' | 'resume';
}): Promise<{
session: Session;
Expand All @@ -437,6 +444,7 @@ export class AcpServer implements Agent {
if (!(await harnessIsAuthed(this.harness))) {
throw RequestError.authRequired();
}
const additionalDirs = validateAdditionalDirectories(params.additionalDirectories) ?? [];
if (!this.conn) {
throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection');
}
Expand All @@ -458,6 +466,7 @@ export class AcpServer implements Agent {
id: params.sessionId,
kaos: acpKaos,
persistenceKaos,
additionalDirs,
sessionStartedProperties: { mode: params.mode },
// @ts-expect-error — see block comment above; mcpServers is a
// kernel-only field that the SDK forwards via spread.
Expand Down Expand Up @@ -1097,3 +1106,46 @@ function sessionSummaryToSessionInfo(summary: SessionSummary): SessionInfo {
updatedAt,
};
}

/**
* Validate the ACP `additionalDirectories` field per protocol spec.
*
* Returns the validated string array when the field is present, or
* `undefined` when the field is absent (`undefined`).
* Throws {@link RequestError.invalidParams} for non-array values,
* non-string entries, empty strings, or non-absolute paths.
*/
export function validateAdditionalDirectories(
dirs: unknown,
): string[] | undefined {
if (dirs === undefined) return undefined;
if (!Array.isArray(dirs)) {
throw RequestError.invalidParams(
{ additionalDirectories: dirs },
'additionalDirectories must be an array',
);
}
for (let i = 0; i < dirs.length; i++) {
const entry = dirs[i];
if (typeof entry !== 'string') {
throw RequestError.invalidParams(
{ additionalDirectories: dirs },
`additionalDirectories[${i}] must be a string`,
);
}
if (entry === '') {
throw RequestError.invalidParams(
{ additionalDirectories: dirs },
`additionalDirectories[${i}] must not be empty`,
);
}
if (!path.isAbsolute(entry)) {
throw RequestError.invalidParams(
{ additionalDirectories: dirs },
`additionalDirectories[${i}] must be an absolute path`,
);
}
}
return dirs;
}

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
49 changes: 48 additions & 1 deletion packages/acp-adapter/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from '@agentclientprotocol/sdk';
import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk';

import { AcpServer } from '../src/server';
import { AcpServer, validateAdditionalDirectories } from '../src/server';
import { TERMINAL_AUTH_METHOD } from '../src';

/** Minimal Client that throws on every callback so tests fail loudly. */
Expand Down 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 Expand Up @@ -202,3 +203,49 @@ describe('AcpServer + AgentSideConnection', () => {
expect(method._meta?.['terminal-auth']).toBeUndefined();
});
});

describe('validateAdditionalDirectories', () => {
it('returns undefined when dirs is undefined', () => {
expect(validateAdditionalDirectories(undefined)).toBeUndefined();
});

it('throws when dirs is null (present non-array value)', () => {
expect(() => validateAdditionalDirectories(null)).toThrow();
});

it('returns the array when dirs is a valid string array', () => {
const dirs = ['/home/user/projects', '/tmp/work'];
expect(validateAdditionalDirectories(dirs)).toEqual(dirs);
});

it('returns the empty array when dirs is an empty array', () => {
expect(validateAdditionalDirectories([])).toEqual([]);
});

it('throws when dirs is not an array', () => {
expect(() => validateAdditionalDirectories('not-an-array')).toThrow();
expect(() => validateAdditionalDirectories(42)).toThrow();
expect(() => validateAdditionalDirectories({})).toThrow();
});

it('throws when a dirs entry is not a string', () => {
expect(() => validateAdditionalDirectories(['/valid', 42])).toThrow();
expect(() => validateAdditionalDirectories(['/valid', null as unknown as string])).toThrow();
expect(() => validateAdditionalDirectories(['/valid', undefined as unknown as string])).toThrow();
});

it('throws when a dirs entry is an empty string', () => {
expect(() => validateAdditionalDirectories(['/valid', ''])).toThrow();
});

it('throws when a dirs entry is not an absolute path', () => {
expect(() => validateAdditionalDirectories(['/valid', 'relative/path'])).toThrow();
expect(() => validateAdditionalDirectories(['/valid', './relative'])).toThrow();
});

it('throws with a message that includes the invalid dirs and index', () => {
expect(() => validateAdditionalDirectories(['/valid', 42])).toThrow(/additionalDirectories\[1\]/);
expect(() => validateAdditionalDirectories(['/valid', ''])).toThrow(/additionalDirectories\[1\]/);
expect(() => validateAdditionalDirectories(['/valid', 'relative'])).toThrow(/additionalDirectories\[1\]/);
});
});
63 changes: 62 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,65 @@ 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('omits additionalDirs when additionalDirectories is absent on load', async () => {
const sessionId = 'sess-load-no-adddir';
const session = makeSessionWithHistory(sessionId, []);
const capturedResumeInputs: Array<Record<string, unknown>> = [];
const harness = {
auth: { status: async () => AUTHED_STATUS },
resumeSession: async (input: Record<string, unknown>) => {
capturedResumeInputs.push(input);
return session;
},
getConfig: async () => ({
providers: {},
defaultModel: 'kimi-coder',
models: makeModelsMap([
{ id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true },
]),
}),
} as unknown as KimiHarness;
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/work',
mcpServers: [],
});

expect(capturedResumeInputs).toHaveLength(1);
expect(
(capturedResumeInputs[0] as { additionalDirs?: unknown }).additionalDirs,
).toEqual([]);
});

it('replays a single assistant text-only turn as agent_message_chunk updates', async () => {
const sessionId = 'sess-text-only';
const history = [
Expand Down
Loading