mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(vscode): normalize migrated session paths (#8062)
* feat(vscode): add legacy migration path normalization utility Introduce `normalizeLegacyPath` and supporting helpers for the legacy session migration path. The utility resolves and normalizes raw path inputs into a stable absolute form, including uppercasing Windows drive letters to match canonical casing used by path filters downstream. - `normalizeLegacyPath`: trims, resolves, normalizes, and calls `fs.realpath`, falling back to the normalized path on failure - `isWindowsDrivePath`: detects Windows-style drive paths cross-platform - `normalizeWindowsDriveLetter`: uppercases the drive letter prefix Unit tests cover empty input, Windows drive detection, drive-letter uppercasing, and `realpath` failure fallback. * refactor(legacy-migration): make `createSession` async to resolve workspace path Wire `normalizeLegacyPath` into `createSession` by converting it to an async function. The session directory is now resolved through the path normalization utility instead of using the raw workspace string directly. Update `parseSession` to await the async `createSession` call, and update all unit tests to handle the now-async API. * fix(legacy-migration): lift path normalization out of `createSession` into `parseSession` Move the `normalizeLegacyPath` call from `createSession` to `parseSession` so that path resolution happens once at the top of the pipeline. The resolved `dir` is then threaded explicitly into `createSession`, `createProject`, and `parseMessagesFromConversation` as a parameter, removing the need for `createSession` to be async. * refactor(messages): make `dir` parameter optional via overloaded signature Allow `parseMessagesFromConversation` to accept either a `string` dir plus an optional `LegacyHistoryItem`, or a `LegacyHistoryItem` directly from which the workspace path is extracted. This removes the requirement for callers to destructure the item before passing it in. Also tighten the path normalization tests to use `path.normalize` and `path.resolve` for cross-platform correctness and drop the empty `afterEach` hook. * test(legacy-migration): fix Windows drive letter uppercasing assertions Replace hardcoded backslash expectations with `path.normalize`/`path.resolve` so the test behaves correctly across platforms. Also align the mock to return its input unchanged and use a lowercase drive letter as the starting value to properly exercise the uppercasing logic. * fix:formatting
This commit is contained in:
@@ -10,11 +10,15 @@ type Assistant = Extract<Data, { role: "assistant" }>
|
||||
export function parseMessagesFromConversation(
|
||||
conversation: LegacyApiMessage[],
|
||||
id: string,
|
||||
dirOrItem?: string | LegacyHistoryItem,
|
||||
item?: LegacyHistoryItem,
|
||||
): Array<NonNullable<Message["body"]>> {
|
||||
const dir = typeof dirOrItem === "string" ? dirOrItem : (dirOrItem?.workspace ?? "")
|
||||
const next = typeof dirOrItem === "string" ? item : dirOrItem
|
||||
|
||||
return conversation
|
||||
.filter((entry) => entry.role === "user" || entry.role === "assistant")
|
||||
.map((entry, index) => parseMessage(entry, index, id, item))
|
||||
.map((entry, index) => parseMessage(entry, index, id, dir, next))
|
||||
.filter((message): message is NonNullable<Message["body"]> => Boolean(message))
|
||||
}
|
||||
|
||||
@@ -22,6 +26,7 @@ function parseMessage(
|
||||
entry: LegacyApiMessage,
|
||||
index: number,
|
||||
id: string,
|
||||
dir: string,
|
||||
item?: LegacyHistoryItem,
|
||||
): NonNullable<Message["body"]> | undefined {
|
||||
const created = entry.ts ?? item?.ts ?? 0
|
||||
@@ -55,8 +60,8 @@ function parseMessage(
|
||||
mode: item?.mode ?? "code",
|
||||
agent: "main",
|
||||
path: {
|
||||
cwd: item?.workspace ?? "",
|
||||
root: item?.workspace ?? "",
|
||||
cwd: dir,
|
||||
root: dir,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
export async function normalizeLegacyPath(input?: string): Promise<string> {
|
||||
const raw = input?.trim()
|
||||
if (!raw) return ""
|
||||
|
||||
// Collapse legacy paths into one stable absolute form before importing them.
|
||||
const normalized = path.normalize(path.resolve(raw))
|
||||
const canonical = normalizeWindowsDriveLetter(normalized)
|
||||
|
||||
return fs.realpath(canonical).catch(() => canonical)
|
||||
}
|
||||
|
||||
export function isWindowsDrivePath(input: string): boolean {
|
||||
return /^[a-z]:[\\/]/i.test(input)
|
||||
}
|
||||
|
||||
function normalizeWindowsDriveLetter(input: string): string {
|
||||
// Match the canonical drive-letter casing used later by Windows path filters.
|
||||
if (!isWindowsDrivePath(input)) return input
|
||||
const head = input[0]
|
||||
if (!head) return input
|
||||
return head.toUpperCase() + input.slice(1)
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import { createProjectID } from "./ids"
|
||||
|
||||
export function createProject(item?: LegacyHistoryItem): NonNullable<Project["body"]> {
|
||||
const project = makeProject()
|
||||
const dir = item?.workspace ?? ""
|
||||
|
||||
project.id = createProjectID(item?.workspace)
|
||||
project.id = createProjectID(dir)
|
||||
|
||||
project.worktree = item?.workspace ?? ""
|
||||
project.worktree = dir
|
||||
|
||||
project.sandboxes = item?.workspace ? [item.workspace] : []
|
||||
project.sandboxes = dir ? [dir] : []
|
||||
|
||||
project.timeCreated = item?.ts ?? 0
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createSession(
|
||||
id: string,
|
||||
item: LegacyHistoryItem | undefined,
|
||||
projectID: string,
|
||||
dir: string,
|
||||
): NonNullable<Session["body"]> {
|
||||
const session = makeSession()
|
||||
|
||||
@@ -15,7 +16,7 @@ export function createSession(
|
||||
|
||||
session.slug = id
|
||||
|
||||
session.directory = item?.workspace ?? ""
|
||||
session.directory = dir
|
||||
|
||||
session.title = item?.task ?? id
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { getApiConversationHistory, parseFile } from "./lib/legacy-conversation"
|
||||
import { parseMessagesFromConversation } from "./lib/messages"
|
||||
import { parsePartsFromConversation } from "./lib/parts/parts"
|
||||
import { normalizeLegacyPath } from "./lib/path"
|
||||
import { createProject } from "./lib/project"
|
||||
import { createSession } from "./lib/session"
|
||||
|
||||
@@ -19,11 +20,13 @@ export interface NormalizedSession {
|
||||
}
|
||||
|
||||
export async function parseSession(id: string, dir: string, item?: LegacyHistoryItem): Promise<NormalizedSession> {
|
||||
const project = createProject(item)
|
||||
const session = createSession(id, item, project.id)
|
||||
const root = await normalizeLegacyPath(item?.workspace)
|
||||
const next = item ? { ...item, workspace: root } : undefined
|
||||
const project = createProject(next)
|
||||
const session = createSession(id, next, project.id, root)
|
||||
const file = await getApiConversationHistory(id, dir)
|
||||
const conversation = parseFile(file)
|
||||
const messages = parseMessagesFromConversation(conversation, id, item)
|
||||
const messages = parseMessagesFromConversation(conversation, id, root, next)
|
||||
const parts = parsePartsFromConversation(conversation, id, item)
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,7 @@ function sample(): LegacyApiMessage[] {
|
||||
|
||||
describe("legacy migration messages", () => {
|
||||
it("parses a basic legacy conversation into ordered user and assistant messages with stable ids", () => {
|
||||
const list = parseMessagesFromConversation(sample(), id, item)
|
||||
const list = parseMessagesFromConversation(sample(), id, item.workspace, item)
|
||||
|
||||
expect(list).toHaveLength(2)
|
||||
expect(list[0]?.data.role).toBe("user")
|
||||
@@ -53,7 +53,7 @@ describe("legacy migration messages", () => {
|
||||
})
|
||||
|
||||
it("creates valid assistant message metadata for the SDK/backend shape", () => {
|
||||
const list = parseMessagesFromConversation(sample(), id, item)
|
||||
const list = parseMessagesFromConversation(sample(), id, item.workspace, item)
|
||||
const msg = list.find((x) => x.data.role === "assistant")
|
||||
|
||||
expect(msg?.data.role).toBe("assistant")
|
||||
@@ -66,7 +66,7 @@ describe("legacy migration messages", () => {
|
||||
})
|
||||
|
||||
it("ignores unsupported legacy entries instead of producing broken messages", () => {
|
||||
const list = parseMessagesFromConversation(sample(), id, item)
|
||||
const list = parseMessagesFromConversation(sample(), id, item.workspace, item)
|
||||
|
||||
expect(list).toHaveLength(2)
|
||||
expect(list.some((x) => x.data.role !== "user" && x.data.role !== "assistant")).toBe(false)
|
||||
@@ -92,6 +92,7 @@ describe("legacy migration messages", () => {
|
||||
},
|
||||
],
|
||||
id,
|
||||
item.workspace,
|
||||
item,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ const item = {
|
||||
describe("legacy migration parser", () => {
|
||||
it("uses the final deterministic ids expected for migration", () => {
|
||||
const project = createProject(item)
|
||||
const session = createSession(id, item, project.id)
|
||||
const session = createSession(id, item, project.id, item.workspace)
|
||||
const msg1 = createMessageID(id, 0)
|
||||
const msg2 = createMessageID(id, 1)
|
||||
const prt1 = createPartID(id, 0, 0)
|
||||
const prt2 = createPartID(id, 1, 0)
|
||||
|
||||
expect(project.id).toBe(createProject(item).id)
|
||||
expect(session.id).toBe(createSession(id, item, project.id).id)
|
||||
expect(session.id).toBe(createSession(id, item, project.id, item.workspace).id)
|
||||
expect(session.id.startsWith("ses_migrated_")).toBe(true)
|
||||
expect(msg1).toBe(createMessageID(id, 0))
|
||||
expect(msg2).toBe(createMessageID(id, 1))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import * as path from "path"
|
||||
|
||||
const realpath = mock(async (input: string) => input)
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
realpath,
|
||||
}))
|
||||
|
||||
const { isWindowsDrivePath, normalizeLegacyPath } = await import("../../../src/legacy-migration/sessions/lib/path")
|
||||
|
||||
describe("legacy migration path", () => {
|
||||
beforeEach(() => {
|
||||
realpath.mockReset()
|
||||
realpath.mockImplementation(async (input: string) => input)
|
||||
})
|
||||
|
||||
it("returns an empty string for empty legacy paths", async () => {
|
||||
expect(await normalizeLegacyPath(" ")).toBe("")
|
||||
expect(realpath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("detects Windows drive paths without relying on runtime platform", () => {
|
||||
expect(isWindowsDrivePath("c:\\repo")).toBe(true)
|
||||
expect(isWindowsDrivePath("C:/repo")).toBe(true)
|
||||
expect(isWindowsDrivePath("/repo")).toBe(false)
|
||||
})
|
||||
|
||||
it("uppercases the Windows drive letter before resolving the final path", async () => {
|
||||
realpath.mockImplementation(async (input: string) => input)
|
||||
|
||||
const value = await normalizeLegacyPath("c:/repo/../repo/file.txt")
|
||||
const expected = path.normalize(path.resolve("c:/repo/../repo/file.txt"))
|
||||
|
||||
expect(realpath).toHaveBeenCalledTimes(1)
|
||||
expect(realpath.mock.calls[0]?.[0]).toBe(expected.replace(/^c:/, "C:"))
|
||||
expect(value).toBe(expected.replace(/^c:/, "C:"))
|
||||
})
|
||||
|
||||
it("falls back to the normalized path when realpath fails", async () => {
|
||||
realpath.mockRejectedValueOnce(new Error("missing"))
|
||||
|
||||
const input = "C:/repo/./child"
|
||||
const value = await normalizeLegacyPath(input)
|
||||
|
||||
expect(value).toBe(path.normalize(path.resolve(input)))
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ describe("legacy migration session", () => {
|
||||
mode: "code",
|
||||
},
|
||||
"project-1",
|
||||
"/workspace/testing",
|
||||
)
|
||||
|
||||
expect(session.projectID).toBe("project-1")
|
||||
@@ -24,15 +25,15 @@ describe("legacy migration session", () => {
|
||||
})
|
||||
|
||||
it("creates a deterministic session id from the legacy task id", () => {
|
||||
const a = createSession("legacy-task-1", undefined, "project-1")
|
||||
const b = createSession("legacy-task-1", undefined, "project-1")
|
||||
const a = createSession("legacy-task-1", undefined, "project-1", "")
|
||||
const b = createSession("legacy-task-1", undefined, "project-1", "")
|
||||
|
||||
expect(a.id).toBe(b.id)
|
||||
expect(a.id.startsWith("ses_migrated_")).toBe(true)
|
||||
})
|
||||
|
||||
it("falls back to the legacy id as title when task metadata is missing", () => {
|
||||
const session = createSession("legacy-task-1", undefined, "project-1")
|
||||
const session = createSession("legacy-task-1", undefined, "project-1", "")
|
||||
|
||||
expect(session.slug).toBe("legacy-task-1")
|
||||
expect(session.title).toBe("legacy-task-1")
|
||||
|
||||
Reference in New Issue
Block a user