mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
* feat(vscode): migrate extension to Node SDK * fix(vscode): address CI failures * fix(vis): handle token count records * fix(vscode): keep chat toolbar and header readable at narrow widths * fix(vscode): map yolo to core yolo permission and honor the global yolo setting * docs(vscode): record Node SDK migration design * docs(vscode): split breaking changes out of the 0.6.0 changelog * fix(vscode): keep a resumed session's thinking effort instead of reapplying the default * fix(vscode): announce session status when a view attaches so the display matches it * fix(vscode): align webview thinking effort handling with the TUI
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
/**
|
|
* Scenario: VS Code discovers and runs legacy kimi-cli migration without touching a real home.
|
|
* Responsibilities: source selection, shared-marker suppression, real migration, retry, and clear reports.
|
|
* Wiring: real temporary files and the public migration package; no stubbed collaborators.
|
|
* Run: pnpm --filter kimi-code test -- legacy-migration.manager.test.ts
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { LegacyMigrationManager } from "../src/migration";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe("legacy migration manager (discovery and migration coordination)", () => {
|
|
it("returns an actionable prompt when the default legacy home contains data", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toMatchObject({
|
|
kind: "legacy-migration",
|
|
actions: [
|
|
{ id: "now", label: "Migrate Now" },
|
|
{ id: "later", label: "Later" },
|
|
],
|
|
sources: [
|
|
{
|
|
sourceHome: rig.sourceHome,
|
|
origin: "default",
|
|
hasConfig: true,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("suppresses the prompt when the shared marker names the same target", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeSharedMarker(rig.sourceHome, rig.targetHome);
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.suppressedSources).toEqual([
|
|
expect.objectContaining({ sourceHome: rig.sourceHome, hasConfig: true }),
|
|
]);
|
|
});
|
|
|
|
it("returns the prompt when the shared marker names a different target", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeSharedMarker(rig.sourceHome, join(rig.root, "another-target"));
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt?.sources).toEqual([
|
|
expect.objectContaining({ sourceHome: rig.sourceHome }),
|
|
]);
|
|
});
|
|
|
|
it("does not duplicate data when the TUI already migrated the same source", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
const existingSession = join(rig.targetHome, "sessions", "existing", "state.json");
|
|
await mkdir(join(existingSession, ".."), { recursive: true });
|
|
await writeFile(existingSession, '{"custom":{"imported_from_kimi_cli":true}}');
|
|
await writeSharedMarker(rig.sourceHome, rig.targetHome);
|
|
|
|
const result = await rig.manager.migrateNow();
|
|
|
|
expect(result.status).toBe("nothing-to-migrate");
|
|
await expect(readFile(existingSession, "utf-8")).resolves.toContain(
|
|
"imported_from_kimi_cli",
|
|
);
|
|
await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).rejects.toThrow();
|
|
});
|
|
|
|
it("conservatively suppresses the prompt when the shared marker is corrupt", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeFile(join(rig.sourceHome, ".migrated-to-kimi-code"), "not-json");
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.suppressedSources).toHaveLength(1);
|
|
});
|
|
|
|
it("discovers a relative legacy KIMI_SHARE_DIR from the workspace and warns about its resolution", async () => {
|
|
const rig = await createRig({
|
|
legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" },
|
|
});
|
|
const shareHome = join(rig.workspaceRoot, "legacy-kimi");
|
|
await writeLegacyConfig(shareHome);
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt?.sources).toEqual([
|
|
expect.objectContaining({
|
|
sourceHome: resolve(shareHome),
|
|
origin: "legacy-vscode-setting",
|
|
}),
|
|
]);
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({ code: "relative-share-dir", sourceHome: resolve(shareHome) }),
|
|
]);
|
|
});
|
|
|
|
it("migrates both the default home and the extra legacy KIMI_SHARE_DIR source", async () => {
|
|
const rig = await createRig({
|
|
legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" },
|
|
});
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
const shareHome = join(rig.workspaceRoot, "legacy-kimi");
|
|
await mkdir(join(shareHome, "skills", "example-skill"), { recursive: true });
|
|
await writeFile(
|
|
join(shareHome, "skills", "example-skill", "SKILL.md"),
|
|
"---\nname: example-skill\ndescription: test fixture\n---\n",
|
|
);
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.sources).toHaveLength(2);
|
|
expect(result.totals).toMatchObject({ configFiles: 1, skills: 1 });
|
|
await expect(
|
|
readFile(join(rig.targetHome, "skills", "example-skill", "SKILL.md"), "utf-8"),
|
|
).resolves.toContain("example-skill");
|
|
});
|
|
|
|
it("ignores legacy environment variables other than KIMI_SHARE_DIR", async () => {
|
|
const rig = await createRig({
|
|
legacyEnvironmentVariables: {
|
|
KIMI_CODE_HOME: join(tmpdir(), "must-not-be-read"),
|
|
PATH: join(tmpdir(), "must-not-be-used"),
|
|
},
|
|
});
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery).toMatchObject({ prompt: null, warnings: [] });
|
|
});
|
|
|
|
it("ignores a non-string legacy KIMI_SHARE_DIR with a clear warning", async () => {
|
|
const rig = await createRig({
|
|
legacyEnvironmentVariables: { KIMI_SHARE_DIR: 42, HTTPS_PROXY: "https://example.test" },
|
|
});
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "invalid-share-dir",
|
|
message: expect.stringContaining("non-empty string"),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("ignores a relative legacy KIMI_SHARE_DIR when no workspace can resolve it", async () => {
|
|
const rig = await createRig({
|
|
workspaceRoot: null,
|
|
legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" },
|
|
});
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "invalid-share-dir",
|
|
message: expect.stringContaining("no workspace is open"),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("bypasses a completed marker on explicit retry and runs the real migration", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeSharedMarker(rig.sourceHome, rig.targetHome);
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.totals.configFiles).toBe(1);
|
|
await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toContain(
|
|
"merge_all_available_skills",
|
|
);
|
|
});
|
|
|
|
it("keeps the migrated target unchanged when an explicit retry is repeated", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await rig.manager.retry();
|
|
const firstTarget = await readFile(join(rig.targetHome, "config.toml"), "utf-8");
|
|
|
|
const secondResult = await rig.manager.retry();
|
|
|
|
expect(secondResult.status).toBe("completed");
|
|
await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toBe(
|
|
firstTarget,
|
|
);
|
|
});
|
|
|
|
it("reports a corrupt session as a partial migration without rolling back valid data", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeCorruptLegacySession(rig.sourceHome, rig.workspaceRoot);
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("partial");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({
|
|
code: "session-failed",
|
|
message: expect.stringMatching(/corrupt|parseable/i),
|
|
}),
|
|
]);
|
|
await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toContain(
|
|
"merge_all_available_skills",
|
|
);
|
|
});
|
|
|
|
it("discovers an unknown workdir bucket as actionable legacy data", async () => {
|
|
const rig = await createRig();
|
|
const bucket = await writeUnknownWorkdirBucket(rig.sourceHome);
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt?.sources).toEqual([
|
|
expect.objectContaining({
|
|
sourceHome: rig.sourceHome,
|
|
totalSessions: 0,
|
|
sessionIssues: 1,
|
|
}),
|
|
]);
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "legacy-session-unreadable",
|
|
message: expect.stringContaining(bucket),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns a partial result with a manual action for an unknown workdir bucket", async () => {
|
|
const rig = await createRig();
|
|
const bucket = await writeUnknownWorkdirBucket(rig.sourceHome);
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("partial");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({ code: "session-failed", item: bucket }),
|
|
]);
|
|
expect(result.manualActions).toEqual([expect.stringContaining(bucket)]);
|
|
expect(result.warnings).toEqual([
|
|
expect.objectContaining({ code: "legacy-session-unreadable" }),
|
|
]);
|
|
});
|
|
|
|
it("returns a partial result with a warning when a registered bucket is unreadable", async () => {
|
|
const rig = await createRig();
|
|
const workDir = rig.workspaceRoot;
|
|
await mkdir(rig.sourceHome, { recursive: true });
|
|
await writeFile(
|
|
join(rig.sourceHome, "kimi.json"),
|
|
JSON.stringify({ work_dirs: [{ path: workDir, kaos: "local" }] }),
|
|
);
|
|
const bucket = join(
|
|
rig.sourceHome,
|
|
"sessions",
|
|
createHash("md5").update(workDir).digest("hex"),
|
|
);
|
|
await mkdir(join(rig.sourceHome, "sessions"), { recursive: true });
|
|
await writeFile(bucket, "not a directory");
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("partial");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({ code: "session-failed", item: bucket }),
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "legacy-session-unreadable",
|
|
message: expect.stringContaining(bucket),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("keeps an unresolved session source discoverable after a partial run", async () => {
|
|
const rig = await createRig();
|
|
await writeUnknownWorkdirBucket(rig.sourceHome);
|
|
await rig.manager.retry();
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt?.sources).toEqual([
|
|
expect.objectContaining({ sourceHome: rig.sourceHome, sessionIssues: 1 }),
|
|
]);
|
|
});
|
|
|
|
it("reports an unresolved session warning when a shared marker suppresses the prompt", async () => {
|
|
const rig = await createRig();
|
|
const bucket = await writeUnknownWorkdirBucket(rig.sourceHome);
|
|
await writeSharedMarker(rig.sourceHome, rig.targetHome);
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.suppressedSources).toEqual([
|
|
expect.objectContaining({ sourceHome: rig.sourceHome, sessionIssues: 1 }),
|
|
]);
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "legacy-session-unreadable",
|
|
message: expect.stringContaining(bucket),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("reports an unparseable legacy config with a manual review action", async () => {
|
|
const rig = await createRig();
|
|
await mkdir(rig.sourceHome, { recursive: true });
|
|
await writeFile(join(rig.sourceHome, "config.toml"), 'broken = "unterminated');
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("partial");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({ code: "legacy-config-unreadable", item: "config.toml" }),
|
|
]);
|
|
expect(result.manualActions).toEqual([
|
|
expect.stringContaining("review it manually"),
|
|
]);
|
|
});
|
|
|
|
it("reports an unparseable legacy MCP file with a manual review action", async () => {
|
|
const rig = await createRig();
|
|
await mkdir(rig.sourceHome, { recursive: true });
|
|
await writeFile(join(rig.sourceHome, "mcp.json"), "{broken");
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("partial");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({ code: "legacy-mcp-unreadable", item: "mcp.json" }),
|
|
]);
|
|
});
|
|
|
|
it("returns a failed result with the original filesystem reason when the target is blocked", async () => {
|
|
const rig = await createRig();
|
|
await writeLegacyConfig(rig.sourceHome);
|
|
await writeFile(rig.targetHome, "this file blocks the target directory");
|
|
|
|
const result = await rig.manager.retry();
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.sources[0]?.failures).toEqual([
|
|
expect.objectContaining({
|
|
code: "run-failed",
|
|
message: expect.stringMatching(/file|directory|exist|not a directory/i),
|
|
}),
|
|
]);
|
|
expect(result.manualActions).toEqual([
|
|
expect.stringContaining("Migrate Legacy Data"),
|
|
]);
|
|
});
|
|
|
|
it("reports legacy OAuth login as requiring a new login without treating it as migratable data", async () => {
|
|
const rig = await createRig();
|
|
await mkdir(join(rig.sourceHome, "credentials"), { recursive: true });
|
|
await writeFile(join(rig.sourceHome, "credentials", "kimi-code.json"), "{}");
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.prompt).toBeNull();
|
|
expect(discovery.notices.oauthLoginsRequiringRelogin).toEqual([
|
|
{ sourceHome: rig.sourceHome, name: "kimi-code.json" },
|
|
]);
|
|
});
|
|
|
|
it("reports legacy MCP OAuth state as requiring reauthorization", async () => {
|
|
const rig = await createRig();
|
|
await mkdir(join(rig.sourceHome, "mcp-oauth"), { recursive: true });
|
|
await writeFile(join(rig.sourceHome, "mcp-oauth", "example-server"), "{}");
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.notices.mcpOauthServersRequiringReauth).toEqual([
|
|
{ sourceHome: rig.sourceHome, name: "example-server" },
|
|
]);
|
|
});
|
|
|
|
it("reports a configured migration source that is not a directory", async () => {
|
|
const rig = await createRig();
|
|
await writeFile(rig.sourceHome, "not a directory");
|
|
|
|
const discovery = await rig.manager.discover();
|
|
|
|
expect(discovery.warnings).toEqual([
|
|
expect.objectContaining({
|
|
code: "source-not-directory",
|
|
sourceHome: rig.sourceHome,
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
interface RigOptions {
|
|
readonly workspaceRoot?: string | null;
|
|
readonly legacyEnvironmentVariables?: unknown;
|
|
}
|
|
|
|
async function createRig(options: RigOptions = {}): Promise<{
|
|
readonly root: string;
|
|
readonly sourceHome: string;
|
|
readonly targetHome: string;
|
|
readonly workspaceRoot: string;
|
|
readonly manager: LegacyMigrationManager;
|
|
}> {
|
|
const root = await mkdtemp(join(tmpdir(), "vscode-legacy-migration-"));
|
|
temporaryRoots.push(root);
|
|
const sourceHome = join(root, ".kimi");
|
|
const targetHome = join(root, ".kimi-code");
|
|
const workspaceRoot = join(root, "workspace");
|
|
await mkdir(options.workspaceRoot === null ? root : workspaceRoot, { recursive: true });
|
|
const manager = new LegacyMigrationManager({
|
|
targetHome,
|
|
defaultSourceHome: sourceHome,
|
|
workspaceRoot: options.workspaceRoot === undefined ? workspaceRoot : options.workspaceRoot,
|
|
legacyEnvironmentVariables: options.legacyEnvironmentVariables,
|
|
});
|
|
return { root, sourceHome, targetHome, workspaceRoot, manager };
|
|
}
|
|
|
|
async function writeLegacyConfig(sourceHome: string): Promise<void> {
|
|
await mkdir(sourceHome, { recursive: true });
|
|
await writeFile(join(sourceHome, "config.toml"), "merge_all_available_skills = true\n");
|
|
}
|
|
|
|
async function writeSharedMarker(sourceHome: string, targetHome: string): Promise<void> {
|
|
await mkdir(sourceHome, { recursive: true });
|
|
await writeFile(
|
|
join(sourceHome, ".migrated-to-kimi-code"),
|
|
JSON.stringify({ version: 1, target_path: targetHome, runs: [] }),
|
|
);
|
|
}
|
|
|
|
async function writeCorruptLegacySession(
|
|
sourceHome: string,
|
|
workDir: string,
|
|
): Promise<void> {
|
|
await mkdir(sourceHome, { recursive: true });
|
|
await writeFile(
|
|
join(sourceHome, "kimi.json"),
|
|
JSON.stringify({ work_dirs: [{ path: workDir, kaos: "local" }] }),
|
|
);
|
|
const bucket = createHash("md5").update(workDir).digest("hex");
|
|
const sessionDir = join(sourceHome, "sessions", bucket, "corrupt-session");
|
|
await mkdir(sessionDir, { recursive: true });
|
|
await writeFile(join(sessionDir, "context.jsonl"), "not-json\n{broken\n}}}\n");
|
|
await writeFile(join(sessionDir, "state.json"), "{}");
|
|
}
|
|
|
|
async function writeUnknownWorkdirBucket(sourceHome: string): Promise<string> {
|
|
const bucket = join(
|
|
sourceHome,
|
|
"sessions",
|
|
createHash("md5").update("/workspace/not-registered").digest("hex"),
|
|
);
|
|
await mkdir(join(bucket, "legacy-session"), { recursive: true });
|
|
await writeFile(
|
|
join(bucket, "legacy-session", "context.jsonl"),
|
|
'{"role":"user","content":"hello"}\n',
|
|
);
|
|
return bucket;
|
|
}
|