diff --git a/.changeset/multi-root-workspace-dirs.md b/.changeset/multi-root-workspace-dirs.md new file mode 100644 index 0000000000..db719e6297 --- /dev/null +++ b/.changeset/multi-root-workspace-dirs.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Fix the extension ignoring the non-primary folders of a multi-root workspace: they are now passed to the session as additional directories, edits made in them are tracked in File Changes (and can be kept or undone), and a folder added to the workspace mid-conversation reaches the open session instead of waiting for a reload. diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts index e4d4107103..21c1c79f1e 100644 --- a/apps/vscode/src/bridge-handler.ts +++ b/apps/vscode/src/bridge-handler.ts @@ -95,6 +95,11 @@ export class BridgeHandler { return vscode.workspace.workspaceFolders?.[0]?.uri ?? null; } + /** Other multi-root workspace folders, passed to the session as ephemeral additionalDirs. */ + private get additionalWorkspaceDirs(): readonly string[] { + return vscode.workspace.workspaceFolders?.slice(1).map((folder) => folder.uri.fsPath) ?? []; + } + private getWorkDir(webviewId: string): string | null { return this.customWorkDirs.get(webviewId) ?? this.workspaceRoot; } @@ -172,6 +177,7 @@ export class BridgeHandler { model, effort, yoloMode: VSCodeSettings.yoloMode, + additionalDirs: this.additionalWorkspaceDirs, ...(sessionId === undefined ? {} : { sessionId }), }); this.fileManager.setSession(webviewId, baselineSession(runtime)); @@ -181,7 +187,11 @@ export class BridgeHandler { const current = this.runtime.getSession(sessionId); const session = current?.session ?? - (await this.runtime.harness.resumeSession({ id: sessionId, includeSubagents: true })); + (await this.runtime.harness.resumeSession({ + id: sessionId, + includeSubagents: true, + additionalDirs: this.additionalWorkspaceDirs, + })); if (!areSameFsPath(session.workDir, this.requireWorkDir(webviewId))) { if (current === undefined) { await session.close().catch((error: unknown) => { @@ -241,6 +251,33 @@ export class BridgeHandler { : `@${mentionTarget}:${selection.start.line + 1}-${selection.end.line + 1}`; } + /** + * The file's URI if the session may write to it: under its working + * directory, or under one of its additionalDirs (other multi-root workspace + * folders, /add-dir). Additional roots sit outside workDir, where the + * workDir-relative resolver returns undefined, so those are resolved from + * the absolute path instead. Edits there used to be dropped, never reaching + * File Changes and impossible to keep or undo. + */ + private resolveTrackablePath( + session: BaselineSession, + workDirUri: vscode.Uri, + filePath: string, + ): vscode.Uri | undefined { + const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath); + if (resolved !== undefined) { + return isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true }) + ? resolved.uri + : undefined; + } + if (!path.isAbsolute(filePath) && !path.win32.isAbsolute(filePath)) return undefined; + const candidate = vscode.Uri.file(filePath); + const underAdditionalDir = (session.additionalDirs ?? []).some((dir) => + isWorkspacePathContainedSync(vscode.Uri.file(dir), candidate, { allowMissing: true }), + ); + return underAdditionalDir ? candidate : undefined; + } + captureFileBaseline( session: BaselineSession, filePath: string, @@ -262,11 +299,8 @@ export class BridgeHandler { return; } - const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath); - if ( - resolved === undefined || - !isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true }) - ) { + const resolvedUri = this.resolveTrackablePath(session, workDirUri, filePath); + if (resolvedUri === undefined) { this.logRuntimeError( "Unable to capture a file baseline", new Error("File is outside the session working directory"), @@ -274,12 +308,13 @@ export class BridgeHandler { return; } - const capture = this.baselineManager.capture(session, resolved.uri.fsPath); + const capture = this.baselineManager.capture(session, resolvedUri.fsPath); + void capture .then(async () => { await Promise.all( webviewIds.map(async (webviewId) => { - this.fileManager.trackFile(webviewId, resolved.uri.fsPath); + this.fileManager.trackFile(webviewId, resolvedUri.fsPath); await this.fileManager.refreshChanges(webviewId); }), ); @@ -331,14 +366,18 @@ function baselineSession(runtime: SessionRuntime): BaselineSession { id: runtime.id, workDir: runtime.session.workDir, metadata: runtime.summary?.metadata, + additionalDirs: runtime.summary?.additionalDirs, }); } -function baselineSummary(summary: Pick): BaselineSession { +function baselineSummary( + summary: Pick, +): BaselineSession { return { id: summary.id, workDir: summary.workDir, ...(summary.metadata === undefined ? {} : { metadata: summary.metadata }), + ...(summary.additionalDirs === undefined ? {} : { additionalDirs: summary.additionalDirs }), }; } diff --git a/apps/vscode/src/handlers/session.handler.ts b/apps/vscode/src/handlers/session.handler.ts index 817d6960bf..82b2c84dd0 100644 --- a/apps/vscode/src/handlers/session.handler.ts +++ b/apps/vscode/src/handlers/session.handler.ts @@ -261,8 +261,17 @@ function toSessionInfo(summary: SessionSummary): SessionInfo { }; } -function baselineSession(summary: Pick): BaselineSession { - return { id: summary.id, workDir: summary.workDir, metadata: summary.metadata }; +function baselineSession( + summary: Pick & { + readonly additionalDirs?: readonly string[]; + }, +): BaselineSession { + return { + id: summary.id, + workDir: summary.workDir, + metadata: summary.metadata, + additionalDirs: summary.additionalDirs, + }; } function isInsideOrEqual(root: string, candidate: string): boolean { diff --git a/apps/vscode/src/managers/baseline.manager.ts b/apps/vscode/src/managers/baseline.manager.ts index 46498aafea..87025c0af1 100644 --- a/apps/vscode/src/managers/baseline.manager.ts +++ b/apps/vscode/src/managers/baseline.manager.ts @@ -24,6 +24,12 @@ export interface BaselineSession { readonly id: string; readonly workDir: string; readonly metadata?: Readonly>; + /** + * Extra roots the session may write to (multi-root workspace folders, + * /add-dir). Files under these are tracked too, keyed in the manifest by + * their absolute path rather than a workDir-relative one. + */ + readonly additionalDirs?: readonly string[]; } interface ManifestEntry { @@ -186,7 +192,7 @@ export class BaselineManager { `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`, ); } - await restoreFile(session.workDir, resolved.absolutePath, baseline); + await restoreFile(sessionRoots(session), resolved.absolutePath, baseline); }); } @@ -198,7 +204,7 @@ export class BaselineManager { const baseline = await this.readEffectiveBaseline(session, relativePath, manifest); if (baseline === undefined) continue; await restoreFile( - session.workDir, + sessionRoots(session), resolveSessionFile(session, relativePath).absolutePath, baseline, ); @@ -654,6 +660,14 @@ function resolveSessionFile(session: BaselineSession, filePath: string): Resolve relativePath.startsWith(parentPrefix) || paths.isAbsolute(relativePath) ) { + // Outside workDir, but the session may still legitimately write here. + // Keyed by absolute path: a workDir-relative key would escape the root + // (`../other/a.ts`) and could collide with a same-named file in another + // root. `paths.resolve(root, )` returns it unchanged, so the + // key round-trips back through this function on read. + if (isUnderAdditionalDir(session, paths, absolutePath)) { + return { absolutePath, relativePath: absolutePath }; + } throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`); } @@ -663,6 +677,23 @@ function resolveSessionFile(session: BaselineSession, filePath: string): Resolve }; } +function isUnderAdditionalDir( + session: BaselineSession, + paths: path.PlatformPath, + absolutePath: string, +): boolean { + return (session.additionalDirs ?? []).some((dir) => { + const dirRoot = paths.resolve(dir); + const relative = paths.relative(dirRoot, absolutePath); + return ( + relative.length > 0 && + relative !== '..' && + !relative.startsWith(`..${paths.sep}`) && + !paths.isAbsolute(relative) + ); + }); +} + function isWindowsAbsolute(value: string): boolean { return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); } @@ -747,11 +778,11 @@ async function readCurrentFile(absolutePath: string): Promise { - await requireContainedRestorePath(workDir, absolutePath); + await requireContainedRestorePath(roots, absolutePath); if (!baseline.existedBefore) { try { await unlink(absolutePath); @@ -773,13 +804,18 @@ async function restoreFile( } } -async function requireContainedRestorePath(workDir: string, absolutePath: string): Promise { +async function requireContainedRestorePath( + roots: readonly string[], + absolutePath: string, +): Promise { try { - const [realWorkDir, realTarget] = await Promise.all([ - realpath(workDir), + // Resolved through realpath on both sides so a symlink cannot point the + // restore outside the session's own roots. + const [realRoots, realTarget] = await Promise.all([ + Promise.all(roots.map(async (root) => realpath(root))), realExistingPath(absolutePath), ]); - if (relativeFsPath(realWorkDir, realTarget) === undefined) { + if (realRoots.every((root) => relativeFsPath(root, realTarget) === undefined)) { throw new BaselineError(`Refusing to restore path outside the session workspace: "${absolutePath}"`); } } catch (error) { @@ -788,6 +824,11 @@ async function requireContainedRestorePath(workDir: string, absolutePath: string } } +/** The session's writable roots: its working directory plus any additionalDirs. */ +function sessionRoots(session: BaselineSession): readonly string[] { + return [session.workDir, ...(session.additionalDirs ?? [])]; +} + async function realExistingPath(candidate: string): Promise { let current = candidate; while (true) { diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index f3c6db4c6c..7c1156e308 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -45,6 +45,12 @@ export interface OpenSessionOptions { readonly model: string; readonly effort: string; readonly yoloMode: boolean; + /** + * Other multi-root workspace folders (ephemeral, per-session). Must be + * resent on every create/resume: the engine's additional-dirs state is not + * persisted across process restarts. + */ + readonly additionalDirs?: readonly string[]; } /** Extension-host owner for one in-process Node SDK harness. */ @@ -115,8 +121,13 @@ export class KimiRuntime { thinking: normalizeEffort(options.effort), permission: corePermissionForLegacyApproval(defaultApproval), metadata: legacyApprovalMetadata(defaultApproval), + additionalDirs: options.additionalDirs, }) - : await this.harness.resumeSession({ id: requestedId, includeSubagents: true }); + : await this.harness.resumeSession({ + id: requestedId, + includeSubagents: true, + additionalDirs: options.additionalDirs, + }); try { assertSessionWorkDir(session, options.workDir); const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); @@ -274,6 +285,25 @@ async function applySessionSettings( if (status.permission !== permission) { await session.setPermission(permission); } + await syncAdditionalDirs(session, options.additionalDirs); +} + +/** + * Folders added to the workspace after a session opened never reach it + * otherwise: openSession's same-session fast path returns the existing runtime + * without re-running create/resume, the only two places additionalDirs is sent. + * Ephemeral, matching how they are passed at create/resume. + */ +async function syncAdditionalDirs( + session: Session, + additionalDirs: readonly string[] | undefined, +): Promise { + if (additionalDirs === undefined || additionalDirs.length === 0) return; + const existing = session.summary?.additionalDirs ?? []; + for (const dir of additionalDirs) { + if (existing.some((current) => areSameFsPath(current, dir))) continue; + await session.addAdditionalDir(dir, { persist: false }); + } } export function normalizeEffort(effort: string): ThinkingEffort { diff --git a/apps/vscode/test/baseline.manager.test.ts b/apps/vscode/test/baseline.manager.test.ts index 2554f288c5..bcc49521ce 100644 --- a/apps/vscode/test/baseline.manager.test.ts +++ b/apps/vscode/test/baseline.manager.test.ts @@ -335,6 +335,35 @@ describe('baseline boundaries (errors, cleanup, and platform paths)', () => { ); }); + it('tracks a file under an additional workspace root', async () => { + // Multi-root workspace: the agent may write to secondary folders, so an + // edit there has to be captured, listed, and undoable like any other. + const secondRoot = join(root, 'second-root'); + await mkdir(secondRoot, { recursive: true }); + const filePath = join(secondRoot, 'lib.ts'); + await writeFile(filePath, 'original\n', 'utf-8'); + const session: BaselineSession = { ...createSession(), additionalDirs: [secondRoot] }; + + await manager.capture(session, filePath); + await writeFile(filePath, 'edited\n', 'utf-8'); + + const changes = await manager.getChanges(session); + expect(changes.map((change) => change.path)).toEqual([filePath]); + + await manager.undo(session, filePath); + await expect(readFile(filePath, 'utf-8')).resolves.toBe('original\n'); + }); + + it('still rejects a path outside both the workspace and its additional roots', async () => { + const secondRoot = join(root, 'second-root'); + await mkdir(secondRoot, { recursive: true }); + const session: BaselineSession = { ...createSession(), additionalDirs: [secondRoot] }; + + await expect(manager.capture(session, join(root, 'elsewhere.ts'))).rejects.toThrow( + 'is outside workspace', + ); + }); + it('normalizes Windows drive case for an in-workspace baseline', async () => { const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' }; await manager.capture(session, 'C:\\Workspace\\src\\new.ts'); diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 5c6ca4297f..a5c0aa621a 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -475,6 +475,29 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => await vi.waitFor(() => expect(showLogs).toHaveBeenCalledOnce()); }); + it("passes the other multi-root workspace folders as additionalDirs when resuming a session", async () => { + const secondRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-bridge-root2-")); + host.workspaceFolders.push({ uri: new host.Uri(secondRoot) }); + const session = createResumedSession("session-1", root); + host.harness.resumeSession.mockResolvedValueOnce(session as never); + + await bridge.handle( + { + id: "rpc-1", + method: Methods.LoadKimiSessionHistory, + params: { kimiSessionId: "session-1" }, + }, + "view-1", + ); + + expect(host.harness.resumeSession).toHaveBeenCalledWith({ + id: "session-1", + includeSubagents: true, + additionalDirs: [secondRoot], + }); + await rm(secondRoot, { recursive: true, force: true }); + }); + it("returns a readable error when persisted session state is corrupt without wedging the bridge", async () => { host.harness.resumeSession.mockRejectedValueOnce( new Error("Session state is invalid JSON at line 4"), diff --git a/apps/vscode/test/kimi-runtime.test.ts b/apps/vscode/test/kimi-runtime.test.ts index 94fb08f994..f56fca64fc 100644 --- a/apps/vscode/test/kimi-runtime.test.ts +++ b/apps/vscode/test/kimi-runtime.test.ts @@ -51,6 +51,7 @@ interface FakeSessionBoundary { readonly setThinkingEfforts: ThinkingEffort[]; readonly setPermissions: PermissionMode[]; readonly metadataUpdates: JsonObject[]; + readonly addedDirs: { dir: string; persist: boolean }[]; readonly handlerInstallations: { approval: number; question: number }; readonly subscriptionCount: () => number; readonly closeCount: () => number; @@ -69,6 +70,7 @@ function createFakeSession( const setThinkingEfforts: ThinkingEffort[] = []; const setPermissions: PermissionMode[] = []; const metadataUpdates: JsonObject[] = []; + const addedDirs: { dir: string; persist: boolean }[] = []; const handlerInstallations = { approval: 0, question: 0 }; let subscriptions = 0; let closes = 0; @@ -135,6 +137,12 @@ function createFakeSession( metadataUpdates.push(patch); summary = { ...summary, metadata: { ...summary.metadata, ...patch } }; }, + async addAdditionalDir(dir: string, options?: { persist?: boolean }) { + addedDirs.push({ dir, persist: options?.persist ?? true }); + const additionalDirs = [...(summary.additionalDirs ?? []), dir]; + summary = { ...summary, additionalDirs }; + return { additionalDirs }; + }, async close() { closes += 1; }, @@ -146,6 +154,7 @@ function createFakeSession( setThinkingEfforts, setPermissions, metadataUpdates, + addedDirs, handlerInstallations, subscriptionCount: () => subscriptions, closeCount: () => closes, @@ -318,6 +327,51 @@ describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => { expect(opened.subscribers).toEqual(["view-1"]); }); + it("forwards additional workspace directories when creating an SDK session", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.openSession(openOptions({ additionalDirs: ["/workspace-2", "/workspace-3"] })); + + expect(sdk.createInputs[0]?.additionalDirs).toEqual(["/workspace-2", "/workspace-3"]); + }); + + it("forwards additional workspace directories when resuming an SDK session", async () => { + const { runtime, sdk } = createRuntime(); + sdk.addSession("saved-1", "/workspace"); + + await runtime.openSession( + openOptions({ sessionId: "saved-1", additionalDirs: ["/workspace-2"] }), + ); + + expect(sdk.resumeInputs).toEqual([ + { id: "saved-1", includeSubagents: true, additionalDirs: ["/workspace-2"] }, + ]); + }); + + it("adds a workspace folder opened mid-conversation to the already-open session", async () => { + const { runtime, sdk } = createRuntime(); + + const opened = await runtime.openSession(openOptions()); + const boundary = sdk.sessions.get(opened.id); + // Same session, same workDir: openSession takes its fast path and never + // re-runs create/resume, the only places additionalDirs would otherwise + // be sent - so a folder added after this point must arrive through here. + await runtime.openSession(openOptions({ sessionId: opened.id, additionalDirs: ["/late-root"] })); + + expect(boundary?.addedDirs).toEqual([{ dir: "/late-root", persist: false }]); + }); + + it("does not re-add a workspace folder the session already has", async () => { + const { runtime, sdk } = createRuntime(); + + const opened = await runtime.openSession(openOptions({ additionalDirs: ["/root-2"] })); + const boundary = sdk.sessions.get(opened.id); + const addedOnCreate = boundary?.addedDirs.length ?? 0; + await runtime.openSession(openOptions({ sessionId: opened.id, additionalDirs: ["/root-2"] })); + + expect(boundary?.addedDirs.length).toBe(addedOnCreate); + }); + it("accepts the normalized SDK workDir when a Windows session is created", async () => { const { runtime } = createRuntime((workDir) => workDir.replaceAll("\\", "/"));