diff --git a/.changeset/fix-acp-auth-order.md b/.changeset/fix-acp-auth-order.md new file mode 100644 index 000000000..d2b59e55e --- /dev/null +++ b/.changeset/fix-acp-auth-order.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix ACP session load and resume returning parameter errors before authentication errors on malformed directories. diff --git a/.changeset/refresh-sdk-resume-summary.md b/.changeset/refresh-sdk-resume-summary.md new file mode 100644 index 000000000..1494905c7 --- /dev/null +++ b/.changeset/refresh-sdk-resume-summary.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Apply additional session root updates and refresh active resumed session summaries. diff --git a/.changeset/support-acp-additional-directories.md b/.changeset/support-acp-additional-directories.md new file mode 100644 index 000000000..bb0bd07f3 --- /dev/null +++ b/.changeset/support-acp-additional-directories.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/acp-adapter": patch +--- + +Advertise and forward ACP additionalDirectories when creating, loading, and resuming sessions. 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..4b850911b 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -8,6 +8,7 @@ import { Readable, Writable } from 'node:stream'; import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import { AgentSideConnection, @@ -227,6 +228,7 @@ export class AcpServer implements Agent { sse: true, }, sessionCapabilities: { + additionalDirectories: {}, list: {}, resume: {}, }, @@ -281,6 +283,7 @@ 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({ @@ -288,6 +291,7 @@ export class AcpServer implements Agent { 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 @@ -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 @@ -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); @@ -428,6 +434,7 @@ export class AcpServer implements Agent { cwd: string; sessionId: string; mcpServers?: ReadonlyArray; + additionalDirectories?: unknown; mode: 'load' | 'resume'; }): Promise<{ session: Session; @@ -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'); } @@ -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. @@ -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; +} + 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..f5968fbef 100644 --- a/packages/acp-adapter/test/server.test.ts +++ b/packages/acp-adapter/test/server.test.ts @@ -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. */ @@ -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({}); }); @@ -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\]/); + }); +}); diff --git a/packages/acp-adapter/test/session-load.test.ts b/packages/acp-adapter/test/session-load.test.ts index fb3e62807..5d37dc221 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,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> = []; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + resumeSession: async (input: Record) => { + 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 = [ diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index ece351999..0570b8c0b 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,126 @@ 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('omits additionalDirs from createSession when additionalDirectories is absent', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-omit', 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', mcpServers: [] }); + + expect(captured).toHaveLength(1); + expect((captured[0]!.options as { additionalDirs?: readonly string[] }).additionalDirs).toBeUndefined(); + }); + + it('forwards empty additionalDirectories as empty array', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-empty', 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', mcpServers: [], additionalDirectories: [] }); + + expect(captured).toHaveLength(1); + expect((captured[0]!.options as { additionalDirs?: readonly string[] }).additionalDirs).toEqual([]); + }); + + it('rejects non-array additionalDirectories with invalid_params', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + getConfig: async () => ({ providers: {}, models: {} }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect( + client.newSession({ + cwd: '/tmp/work', + mcpServers: [], + additionalDirectories: 'not-an-array' as never, + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('rejects non-string entry in additionalDirectories', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + getConfig: async () => ({ providers: {}, models: {} }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect( + client.newSession({ + cwd: '/tmp/work', + mcpServers: [], + additionalDirectories: [42] as never, + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('rejects empty string entry in additionalDirectories', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + getConfig: async () => ({ providers: {}, models: {} }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect( + client.newSession({ + cwd: '/tmp/work', + mcpServers: [], + additionalDirectories: [''], + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('rejects relative path in additionalDirectories', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + getConfig: async () => ({ providers: {}, models: {} }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect( + client.newSession({ + cwd: '/tmp/work', + mcpServers: [], + additionalDirectories: ['relative/path'], + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + 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..a2426fc4d 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,64 @@ 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('omits additionalDirs when additionalDirectories is absent on resume', async () => { + const sessionId = 'sess-resume-no-adddir'; + const session = makeSessionWithMainConfig(sessionId); + const capturedResumeInputs: Array> = []; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + resumeSession: async (input: Record) => { + 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.resumeSession({ + sessionId, + cwd: '/tmp/work', + mcpServers: [], + }); + + expect(capturedResumeInputs).toHaveLength(1); + expect( + (capturedResumeInputs[0] as { additionalDirs?: unknown }).additionalDirs, + ).toEqual([]); + }); + 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..4c5d6019c 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -22,6 +22,7 @@ import type { ListSessionsOptions, RenameSessionInput, ResumeSessionInput, + ResumedSessionSummary, ReloadSessionInput, SessionSummary, TelemetryClient, @@ -118,8 +119,14 @@ export class KimiHarness { const active = this.activeSessions.get(id); const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; if (active !== undefined) { + let summary: ResumedSessionSummary | undefined; if (kaos !== undefined || persistenceKaos !== undefined) { - await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + summary = await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + } else if (resumeInput.additionalDirs !== undefined) { + summary = await this.rpc.resumeSession({ ...resumeInput, id }); + } + if (summary !== undefined) { + active.updateSummary(summary); } return active; } diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 3e28ad394..b7b4e5440 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -71,14 +71,19 @@ export class Session { return this.resumeState; } + updateSummary(summary: ResumedSessionSummary): void { + this.ensureOpen(); + this.summary = summary; + this.resumeState = resumeStateFromSummary(summary); + } + async reloadSession(options?: ReloadSessionOptions): Promise { this.ensureOpen(); const summary = await this.rpc.reloadSession({ sessionId: this.id, forcePluginSessionStartReminder: options?.forcePluginSessionStartReminder, }); - this.summary = summary; - this.resumeState = resumeStateFromSummary(summary); + this.updateSummary(summary); return summary; } diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 5bd3d4a64..9a0921e12 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: '', @@ -687,6 +710,13 @@ effort = "medium" const resumed = await harness.resumeSession({ id: session.id, kaos }); expect(resumed).toBe(session); + expect(resumed.summary?.additionalDirs).toEqual([]); + expect(resumed.getResumeState()).toMatchObject({ + sessionMetadata: { + title: '', + }, + agents: {}, + }); expect(rpc.resumeCalls).toHaveLength(1); expect(rpc.resumeCalls[0]).toMatchObject({ input: { id: 'ses_active' }, @@ -694,6 +724,42 @@ 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(resumed.summary?.additionalDirs).toEqual(['/tmp/extra']); + expect(resumed.getResumeState()).toMatchObject({ + sessionMetadata: { + title: '', + }, + agents: {}, + }); + expect(rpc.plainResumeCalls).toEqual([ + { + id: 'ses_active_dirs', + additionalDirs: ['/tmp/extra'], + }, + ]); + expect(rpc.resumeCalls).toHaveLength(0); + }); }); function coreSessionIds(harness: KimiHarness): readonly string[] {