Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/resume-cross-workdir-sessions.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import {
KimiHarness,
log,
Expand Down Expand Up @@ -158,6 +159,22 @@ async function resolvePromptSession(
setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<ResolvedPromptSession> {
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) {
Comment thread
kermanx marked this conversation as resolved.
stderr.write(
`${chalk.yellow(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.workDir}" && kimi -r ${opts.session}`,
Comment thread
kermanx marked this conversation as resolved.
)}\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(
Expand Down
19 changes: 17 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
kermanx marked this conversation as resolved.
`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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/test/tui/kimi-tui-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}),
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ export interface ExportSessionResult {
}

export interface ListSessionsPayload {
readonly workDir: string;
readonly workDir?: string;
readonly sessionId?: string;
}

export interface CoreInfo {
Expand Down
8 changes: 2 additions & 6 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.resumeSession({ sessionId: id });
}

async listSessions(input: ListSessionsPayload): Promise<readonly SessionSummary[]> {
const options = input;
return this.sessionStore.list({
...options,
workDir: requiredWorkDir('listSessions', options.workDir),
});
async listSessions(input: ListSessionsPayload = {}): Promise<readonly SessionSummary[]> {
return this.sessionStore.list(input);
}

async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise<void> {
Expand Down
66 changes: 64 additions & 2 deletions packages/agent-core/src/session/store/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,27 @@ export class SessionStore {
await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
}

async list(options: ListSessionsPayload): Promise<readonly SessionSummary[]> {
const workDir = normalizeWorkDir(options.workDir);
async list(options: ListSessionsPayload = {}): Promise<readonly SessionSummary[]> {
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<readonly SessionSummary[]> {
const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir));
let entries;
try {
Expand All @@ -157,6 +176,38 @@ export class SessionStore {
return sessions;
}

private async listSessionId(sessionId: string): Promise<readonly SessionSummary[]> {
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<readonly SessionSummary[]> {
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<SessionSummary | undefined> {
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<string> {
return (await this.findExistingSessionEntry(id)).sessionDir;
}
Expand Down Expand Up @@ -287,6 +338,17 @@ async function readOptionalState(sessionDir: string): Promise<SessionSummaryStat
}
}

function normalizeRequiredWorkDir(workDir: string): string {
if (workDir.trim() === '') {
throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, 'listSessions requires workDir');
}
return normalizeWorkDir(workDir);
}

function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
return sessionId === undefined ? undefined : sessionId.trim();
}

function normalizeForkTitle(title: string | undefined, fallback: unknown): string {
if (title !== undefined) {
const normalized = title.trim();
Expand Down
2 changes: 1 addition & 1 deletion packages/node-sdk/src/kimi-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export class KimiHarness {
return result;
}

async listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]> {
async listSessions(options: ListSessionsOptions = {}): Promise<readonly SessionSummary[]> {
return this.rpc.listSessions(options);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/node-sdk/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export class SDKRpcClient {
return rpc.closeSession({ sessionId: input.sessionId });
}

async listSessions(input: ListSessionsOptions): Promise<readonly SessionSummary[]> {
async listSessions(input: ListSessionsOptions = {}): Promise<readonly SessionSummary[]> {
const rpc = await this.getRpc();
return rpc.listSessions(input);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/node-sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export interface ExportSessionResult {
}

export interface ListSessionsOptions {
readonly workDir: string;
readonly workDir?: string;
readonly sessionId?: string;
}

export interface GetConfigOptions {
Expand Down
8 changes: 8 additions & 0 deletions packages/node-sdk/test/create-session-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
68 changes: 63 additions & 5 deletions packages/node-sdk/test/list-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<KimiError>);
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();
}
Expand Down
Loading