diff --git a/.changeset/resume-cross-workdir-sessions.md b/.changeset/resume-cross-workdir-sessions.md new file mode 100644 index 0000000000..1e3108e355 --- /dev/null +++ b/.changeset/resume-cross-workdir-sessions.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Support querying sessions by sessionId or workDir in listSessions, and show a helpful cd command when resuming a session from a different working directory. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index c3b1795985..e639aed0b8 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -5,6 +5,7 @@ import { track, withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; +import chalk from 'chalk'; import { KimiHarness, log, @@ -158,6 +159,22 @@ async function resolvePromptSession( setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { if (opts.session !== undefined) { + const sessions = await harness.listSessions({ sessionId: opts.session, workDir }); + const target = sessions[0]; + if (target === undefined) { + throw new Error(`Session "${opts.session}" not found.`); + } + if (target.workDir !== workDir) { + stderr.write( + `${chalk.yellow( + `Session "${opts.session}" was created under a different directory.\n` + + ` cd "${target.workDir}" && kimi -r ${opts.session}`, + )}\n\n`, + ); + throw new Error( + `Session "${opts.session}" was created under a different directory.`, + ); + } const session = await harness.resumeSession({ id: opts.session }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index da6e0d5d13..ad69644dcc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -448,11 +448,26 @@ export class KimiTUI { } if (startup.sessionFlag !== undefined) { - const sessions = await this.harness.listSessions({ workDir }); - const target = sessions.find((candidate) => candidate.id === startup.sessionFlag); + const sessions = await this.harness.listSessions({ + sessionId: startup.sessionFlag, + workDir, + }); + const target = sessions[0]; if (target === undefined) { throw new Error(`Session "${startup.sessionFlag}" not found.`); } + if (target.workDir !== workDir) { + this.state.ui.stop(); + process.stderr.write( + `${chalk.yellow( + `Session "${startup.sessionFlag}" was created under a different directory.\n` + + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, + )}\n\n`, + ); + throw new Error( + `Session "${startup.sessionFlag}" was created under a different directory.`, + ); + } session = await this.harness.resumeSession({ id: startup.sessionFlag }); shouldReplayHistory = true; } else { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 74341b0844..b62cf8e4cb 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -56,7 +56,7 @@ const mocks = vi.hoisted(() => { ), harnessCreateSession: vi.fn(async () => session), harnessResumeSession: vi.fn(async () => session), - harnessListSessions: vi.fn(async () => [{ id: 'ses_previous' }]), + harnessListSessions: vi.fn(async () => [{ id: 'ses_previous', workDir: process.cwd() }]), harnessClose: vi.fn(), harnessTrack: vi.fn(), harnessGetCachedAccessToken: vi.fn(), diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index e36abe15d3..0b1ba55e9f 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -705,7 +705,7 @@ describe("KimiTUI startup", () => { it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-target" }]), + listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 218bf6667c..99e244d459 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -105,7 +105,8 @@ export interface ExportSessionResult { } export interface ListSessionsPayload { - readonly workDir: string; + readonly workDir?: string; + readonly sessionId?: string; } export interface CoreInfo { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 719fd7e81f..e71972bcdc 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -308,12 +308,8 @@ export class KimiCore implements PromisableMethods { return this.resumeSession({ sessionId: id }); } - async listSessions(input: ListSessionsPayload): Promise { - const options = input; - return this.sessionStore.list({ - ...options, - workDir: requiredWorkDir('listSessions', options.workDir), - }); + async listSessions(input: ListSessionsPayload = {}): Promise { + return this.sessionStore.list(input); } async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise { diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 6c3965d798..58853c875a 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -135,8 +135,27 @@ export class SessionStore { await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); } - async list(options: ListSessionsPayload): Promise { - const workDir = normalizeWorkDir(options.workDir); + async list(options: ListSessionsPayload = {}): Promise { + const workDir = + options.workDir === undefined ? undefined : normalizeRequiredWorkDir(options.workDir); + const sessionId = normalizeOptionalSessionId(options.sessionId); + + if (workDir !== undefined) { + if (sessionId !== undefined) { + const local = await this.summaryFromWorkDirSession(sessionId, workDir); + if (local !== undefined) return [local]; + return this.listSessionId(sessionId); + } + return this.listWorkDir(workDir); + } + + if (sessionId !== undefined) { + return this.listSessionId(sessionId); + } + return this.listAll(); + } + + private async listWorkDir(workDir: string): Promise { const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir)); let entries; try { @@ -157,6 +176,38 @@ export class SessionStore { return sessions; } + private async listSessionId(sessionId: string): Promise { + try { + return [await this.get(sessionId)]; + } catch (error) { + if (error instanceof KimiError && error.code === ErrorCodes.SESSION_NOT_FOUND) { + return []; + } + throw error; + } + } + + private async listAll(): Promise { + const index = await readSessionIndex(this.homeDir, this.sessionsDir); + const sessions: SessionSummary[] = []; + for (const entry of index.values()) { + if (!(await isDirectory(entry.sessionDir))) continue; + sessions.push(await this.summaryFromDir(entry.sessionId, entry.sessionDir, entry.workDir)); + } + sessions.sort(compareSessionSummary); + return sessions; + } + + private async summaryFromWorkDirSession( + sessionId: string, + workDir: string, + ): Promise { + if (!isSafeSessionId(sessionId)) return undefined; + const sessionDir = this.sessionDirFor({ id: sessionId, workDir }); + if (!(await isDirectory(sessionDir))) return undefined; + return this.summaryFromDir(sessionId, sessionDir, workDir); + } + async assertDirectory(id: string): Promise { return (await this.findExistingSessionEntry(id)).sessionDir; } @@ -287,6 +338,17 @@ async function readOptionalState(sessionDir: string): Promise { + async listSessions(options: ListSessionsOptions = {}): Promise { return this.rpc.listSessions(options); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index a2041af5cc..a65b3e7165 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -167,7 +167,7 @@ export class SDKRpcClient { return rpc.closeSession({ sessionId: input.sessionId }); } - async listSessions(input: ListSessionsOptions): Promise { + async listSessions(input: ListSessionsOptions = {}): Promise { const rpc = await this.getRpc(); return rpc.listSessions(input); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index cc031ed8c1..c1e952541a 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -113,7 +113,8 @@ export interface ExportSessionResult { } export interface ListSessionsOptions { - readonly workDir: string; + readonly workDir?: string; + readonly sessionId?: string; } export interface GetConfigOptions { diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 8b0d94bf12..b46ff7f55a 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -244,6 +244,14 @@ describe('KimiHarness.createSession transport link', () => { expect(summary?.sessionDir).toContain(join(homeDir, 'sessions')); expect(existsSync(join(summary!.sessionDir, 'state.json'))).toBe(true); expect(await readFile(join(homeDir, 'session_index.jsonl'), 'utf-8')).toContain(session.id); + + const summariesById = await harness.listSessions({ sessionId: session.id }); + expect(summariesById).toHaveLength(1); + expect(summariesById[0]).toMatchObject({ + id: session.id, + workDir, + }); + await expect(harness.listSessions({ sessionId: 'ses_missing' })).resolves.toEqual([]); } finally { await harness.close(); } diff --git a/packages/node-sdk/test/list-sessions.test.ts b/packages/node-sdk/test/list-sessions.test.ts index 63de606d70..f9c5775492 100644 --- a/packages/node-sdk/test/list-sessions.test.ts +++ b/packages/node-sdk/test/list-sessions.test.ts @@ -144,6 +144,58 @@ describe('SessionStore.list', () => { expect(sessions.map((session) => session.id)).toEqual(['ses_list_a']); }); + it('uses the workDir bucket before the session index when sessionId is provided', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const store = new SessionStore(homeDir); + + const local = await store.create({ id: 'ses_bucket_hit', workDir }); + await rm(sessionIndexPath(homeDir), { force: true }); + + const sessions = await store.list({ workDir, sessionId: local.id }); + expect(sessions.map((session) => session.id)).toEqual([local.id]); + }); + + it('falls back to the session index when a workDir-scoped sessionId is not in that bucket', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const otherWorkDir = await makeTempDir(); + const store = new SessionStore(homeDir); + + await store.create({ id: 'ses_local', workDir }); + const other = await store.create({ id: 'ses_index_fallback', workDir: otherWorkDir }); + + const sessions = await store.list({ workDir, sessionId: other.id }); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + id: other.id, + workDir: otherWorkDir, + }); + }); + + it('lists every indexed session when no filters are provided', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const otherWorkDir = await makeTempDir(); + const store = new SessionStore(homeDir); + + await store.create({ id: 'ses_all_a', workDir }); + await store.create({ id: 'ses_all_b', workDir: otherWorkDir }); + + const sessions = await store.list(); + expect(sessions.map((session) => session.id).toSorted()).toEqual([ + 'ses_all_a', + 'ses_all_b', + ]); + }); + + it('returns an empty array when a sessionId filter is unknown', async () => { + const homeDir = await makeTempDir(); + const store = new SessionStore(homeDir); + + await expect(store.list({ sessionId: 'ses_missing' })).resolves.toEqual([]); + }); + it('reads title from customTitle before title', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); @@ -236,18 +288,24 @@ describe('KimiHarness.listSessions', () => { } }); - it('rejects undefined payload as KimiError(internal)', async () => { + it('lists all sessions when no payload is provided', async () => { const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const otherWorkDir = await makeTempDir(); const harness = new KimiHarness({ identity: TEST_IDENTITY, homeDir, }); try { - await expect(harness.listSessions(undefined as never)).rejects.toMatchObject({ - name: 'KimiError', - code: 'internal', - } satisfies Partial); + await harness.createSession({ id: 'ses_harness_all_a', workDir }); + await harness.createSession({ id: 'ses_harness_all_b', workDir: otherWorkDir }); + + const sessions = await harness.listSessions(); + expect(sessions.map((session) => session.id).toSorted()).toEqual([ + 'ses_harness_all_a', + 'ses_harness_all_b', + ]); } finally { await harness.close(); }