diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 000000000..90636cea5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,91 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; + +type AddDirChoice = 'session' | 'remember' | 'cancel'; + +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { + const input = args.trim(); + const session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + const additionalDirs = session?.summary?.additionalDirs ?? []; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); + return; + } + + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: 'session', + label: 'Yes, for this session', + }, + { + value: 'remember', + label: 'Yes, and remember this directory', + }, + { + value: 'cancel', + label: 'No', + }, + ], + onSelect: (value) => { + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); + }, + onCancel: () => { + host.restoreEditor(); + host.showStatus(`Did not add ${input} as a working directory.`); + }, + }), + ); +} + +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( + host: SlashCommandHost, + sessionId: string, + path: string, + choice: AddDirChoice, +): Promise { + host.restoreEditor(); + + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); + return; + } + + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7053d6806..dcfb90473 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -38,6 +38,7 @@ import { } from './config'; import { handleGoalCommand } from './goal'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; @@ -62,6 +63,7 @@ import { handleWebCommand } from './web'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; export { handleCopyCommand } from './copy'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, @@ -280,6 +282,9 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'add-dir': + await handleAddDirCommand(host, args); + return; case 'experiments': await showExperimentsPanel(host); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 8a1e39fc8..063bcd7bf 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,3 +1,7 @@ +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; @@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'off', description: 'Turn swarm mode off' }, ]; +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); @@ -41,6 +49,89 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', @@ -154,6 +245,15 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'add-dir', + aliases: [], + description: 'Add or list an additional workspace directory', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | ', + completeArgs: addDirArgumentCompletions, + }, { name: 'experiments', aliases: ['experimental'], diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index e5cbefa1c..b1677e87b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -553,7 +553,7 @@ export class CustomEditor extends Editor { }; // Reopen path / argument completion right after a `/` is typed - // (e.g. a slash-command path argument or an `@dir/` mention). + // (e.g. `/add-dir /` or an `@dir/` mention). if (textBeforeCursor.endsWith('/')) { const isAtMention = extractAtPrefix(textBeforeCursor) !== null; if (isAtMention) { @@ -562,7 +562,7 @@ export class CustomEditor extends Editor { // In bash mode `/` is a path separator, not a slash command. A bare // leading `/` is already handled by the tryTriggerAutocomplete shadow // in the constructor; this branch covers the inline case (e.g. `ls /`, - // `cat /etc/`) that pi-tui never auto-triggers. force:true + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true // is required so pi-tui's own slash-command handling is bypassed — // force:false would let it pop up subcommand completions. if (textBeforeCursor.trimStart() !== '/') { diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 44438535b..722682db6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -176,8 +176,8 @@ export class FileMentionProvider implements AutocompleteProvider { // In bash mode `/` is a path separator, not a slash command. Skip slash // command argument handling so an absolute path that happens to start with - // a command name completes inside the path instead of returning the - // command's argument completions. + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. if (this.getInputMode() !== 'bash') { const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); if (slashArgumentSuggestions !== null) { @@ -190,9 +190,9 @@ export class FileMentionProvider implements AutocompleteProvider { if (inner === null || this.getInputMode() !== 'bash') { return inner; } - // In bash mode `/` is a path separator; hide dot-prefixed entries - // (hidden files) from the path completer. Ordinary prompt-mode path - // completion is left as-is. + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; } catch { return null; @@ -247,9 +247,9 @@ function isExecutableFd(fdPath: string): boolean { } /** - * Skip every entry whose name starts with `.` (hidden files). pi-tui's path - * completer sets `label` to the entry basename, with a trailing `/` for - * directories. + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. */ function isDotPrefixedEntry(item: AutocompleteItem): boolean { const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts new file mode 100644 index 000000000..0c3381a87 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleAddDirCommand } from '#/tui/commands/add-dir'; +import { dispatchInput, type SlashCommandHost } from '#/tui/commands/dispatch'; + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function makeHost(additionalDirs: readonly string[] = []) { + const state = { + appState: { + additionalDirs, + streamingPhase: 'idle', + isCompacting: false, + }, + }; + let mountedPanel: MountedPanel | null = null; + const session = { + id: 'session-1', + summary: { + additionalDirs, + }, + addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({ + additionalDirs: [...additionalDirs, path], + projectRoot: '/repo', + configPath: '/repo/.kimi-code/local.toml', + persisted: options.persist, + })), + }; + const host = { + state, + session, + skillCommandMap: new Map(), + setAppState: vi.fn((patch: Record) => Object.assign(state.appState, patch)), + refreshSlashCommandAutocomplete: vi.fn(), + appendTranscriptEntry: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + sendNormalUserInput: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(() => { + mountedPanel = null; + }), + } as unknown as SlashCommandHost & { + session: typeof session; + state: typeof state; + setAppState: ReturnType; + refreshSlashCommandAutocomplete: ReturnType; + appendTranscriptEntry: ReturnType; + showError: ReturnType; + showStatus: ReturnType; + sendNormalUserInput: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + }; + return { + host, + session, + getMountedPanel: () => mountedPanel, + }; +} + +describe('handleAddDirCommand', () => { + it('shows the empty message when no additional dirs are configured', async () => { + const { host } = makeHost(); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No additional directories configured.'); + }); + + it('lists current additional dirs for no args', async () => { + const { host } = makeHost(['/repo/shared', '/repo/docs']); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith( + 'Additional directories:\n /repo/shared\n /repo/docs', + ); + }); + + it('lists current additional dirs for the list subcommand', async () => { + const { host } = makeHost(['/repo/shared']); + + await handleAddDirCommand(host, 'list'); + + expect(host.showStatus).toHaveBeenCalledWith('Additional directories:\n /repo/shared'); + }); + + it('renders the add-dir confirmation without option descriptions', async () => { + const { host, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + + const rendered = getMountedPanel()?.render(120).map(strip).join('\n') ?? ''; + expect(rendered).toContain('Add directory to workspace: ../shared'); + expect(rendered).toContain('Yes, for this session'); + expect(rendered).toContain('Yes, and remember this directory'); + expect(rendered).toContain('No'); + expect(rendered).not.toContain('Use this directory in the current session only'); + expect(rendered).not.toContain('Save this directory to the project workspace config'); + expect(rendered).not.toContain('Do not add this directory.'); + }); + + it('adds a workspace dir for this session only after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false }); + }); + expect(host.restoreEditor).toHaveBeenCalledOnce(); + expect(host.setAppState).toHaveBeenCalledWith({ + additionalDirs: ['../shared'], + }); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n For this session only', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('adds a remembered workspace dir after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true }); + }); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('does not add a workspace dir when the confirmation is cancelled', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Did not add ../shared as a working directory.'); + }); + + it('routes /add-dir errors through the slash-command dispatcher error handler', async () => { + const { host, session, getMountedPanel } = makeHost(); + session.addAdditionalDir.mockRejectedValueOnce(new Error('workspace.additional_dir must exist and be a directory')); + + dispatchInput(host, '/add-dir ../other'); + await vi.waitFor(() => { + expect(getMountedPanel()).not.toBeNull(); + }); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'workspace.additional_dir must exist and be a directory', + ); + }); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..bc4c5894f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -3,6 +3,7 @@ import { findBuiltInSlashCommand, parseSlashInput, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, swarmArgumentCompletions, type KimiSlashCommand, @@ -73,6 +74,27 @@ describe('built-in slash command registry', () => { expect(values('Ship feature X')).toBeNull(); }); + it('offers add-dir list and directory argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = addDirArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', @@ -126,6 +148,7 @@ describe('built-in slash command registry', () => { expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ + 'add-dir', 'compact', 'btw', 'editor', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index ef2422b3a..614553bb4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -40,6 +40,11 @@ describe('resolveSlashCommandInput', () => { name: 'title', args: 'New title', }); + expect(resolve('/add-dir list')).toMatchObject({ + kind: 'builtin', + name: 'add-dir', + args: 'list', + }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); expect(resolve('/btw')).toMatchObject({ kind: 'builtin', @@ -89,6 +94,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); expect(resolve('/experiments', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -122,6 +132,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); expect(resolve('/experiments', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'experiments', diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index fcb54182f..079c81670 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -27,6 +27,11 @@ const SLASH_COMMANDS: SlashCommandInfo[] = [ description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)", }, { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" }, + { + name: "add-dir", + aliases: [], + description: "Add a directory to the workspace. Usage: /add-dir ", + }, { name: "export", aliases: [], description: "Export current session context to a markdown file" }, { name: "import", aliases: [], description: "Import context from a file or session ID" }, ]; diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index ba469c17c..087c823fb 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -22,6 +22,7 @@ const HOST_COMMANDS = new Set([ "auto", "afk", "plan", + "add-dir", "export", "import", ]); @@ -91,6 +92,9 @@ export async function runHostSlashCommand( case "plan": await runPlanCommand(runtime, command.args, emit); break; + case "add-dir": + await runAddDirCommand(runtime, command.args, emit); + break; case "export": await exportContext(runtime, command.args, emit); break; @@ -163,6 +167,23 @@ async function runPlanCommand( : "Plan mode ON."); } +async function runAddDirCommand( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const input = stripMatchingQuotes(args.trim()); + if (!input || input.toLowerCase() === "list") { + const dirs = runtime.session.summary?.additionalDirs ?? []; + emit(dirs.length === 0 + ? "No additional directories. Usage: /add-dir " + : ["Additional directories:", ...dirs.map((path) => ` - ${path}`)].join("\n")); + return; + } + const result = await runtime.session.addAdditionalDir(input, { persist: false }); + emit(`Added directory to workspace: ${result.additionalDirs.at(-1) ?? input}`); +} + async function exportContext( runtime: SessionRuntime, args: string, diff --git a/apps/vscode/test/kimi-harness.integration.test.ts b/apps/vscode/test/kimi-harness.integration.test.ts index 19e7d24bc..21c466b84 100644 --- a/apps/vscode/test/kimi-harness.integration.test.ts +++ b/apps/vscode/test/kimi-harness.integration.test.ts @@ -371,6 +371,7 @@ describe("VS Code Kimi harness integration (shares one in-process SDK home)", () "yolo", "auto", "plan", + "add-dir", "export", "import", "skill:review", @@ -1089,6 +1090,20 @@ describe("VS Code Kimi harness integration (shares one in-process SDK home)", () expect(rig.provider.requests).toHaveLength(0); }); + it("keeps a slash-added directory after VS Code closes and resumes the session", async () => { + const rig = await createRuntimeRig(); + const additionalDir = join(rig.workDir, "directory with spaces"); + await mkdir(additionalDir); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, `/add-dir "${additionalDir}"`)).resolves.toBe(true); + const sessionId = runtime.id; + await rig.runtime.detachView("view-1"); + const resumed = await openRuntimeSession(rig, sessionId); + + expect(resumed.session.summary?.additionalDirs).toContain(additionalDir); + }); + it("rejects an invalid plan subcommand without leaving the runtime busy", async () => { const rig = await createRuntimeRig(); const runtime = await openRuntimeSession(rig); diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 725c20d84..8eda5de72 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -772,7 +772,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map( () => [], ); -export class SessionWorkspaceContextService implements ISessionWorkspaceContext { +export class SessionWorkspaceContextService extends Disposable implements ISessionWorkspaceContext { declare readonly _serviceBrand: undefined; constructor( @ISessionStateService private readonly states: ISessionStateService, @ISessionContext ctx: ISessionContext, + @ISessionWorkspaceInfo workspaceInfo: ISessionWorkspaceInfo, ) { + super(); this.states.register(workspaceContextWorkDirKey); this.states.register(workspaceContextAdditionalDirsKey); this.states.set(workspaceContextWorkDirKey, resolve(ctx.cwd)); this.states.set(workspaceContextAdditionalDirsKey, [ - ...new Set((ctx.additionalDirs ?? []).map((d) => resolve(d))), + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), ]); + this._register( + workspaceInfo.onDidChange(() => { + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + }), + ); } private get _workDir(): string { diff --git a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts new file mode 100644 index 000000000..209397ce1 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts @@ -0,0 +1,32 @@ +/** + * `workspaceInfo` domain (L1) — seeded workspace-directory data contract. + * + * Defines `ISessionWorkspaceInfo`, the pure-data injection contract the + * Workspace-scope `workspaceDirs` hands to every Session scope it creates: + * the workspace's additional directory set as a live read view plus its + * change event. The contract carries no IO — persistence + * (`.kimi-code/local.toml`), caller-dir merging and file watching all live + * on the workspace side; the Session-scope `workspaceContext` read view + * reads this seed and refreshes itself off `onDidChange`. Seeded into the + * Session scope by `workspaceHandler` when the session is materialized. + * Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionWorkspaceInfo { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionWorkspaceInfo: ServiceIdentifier = + createDecorator('sessionWorkspaceInfo'); + +export function sessionWorkspaceInfoSeed(info: ISessionWorkspaceInfo): ScopeSeed { + return [[ISessionWorkspaceInfo as ServiceIdentifier, info]]; +} diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts new file mode 100644 index 000000000..eabcf1ca2 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts @@ -0,0 +1,50 @@ +/** + * `workspaceDirs` domain (L3) — Workspace-scoped additional-directory set + * contract. + * + * Defines `IWorkspaceDirs`, the handler-level owner of the workspace's + * `{root, additionalDirs[]}` set: at handler materialization it loads the + * project-local `.kimi-code/local.toml` set; afterwards `addDir` mutations + * (persisted appends or session-caller in-memory unions) and fs watch on + * `local.toml` (cross-process edits) refresh the set, fanning the change + * out to every session of the handler through the `ISessionWorkspaceInfo` + * seed (`sessionInfo()`). The set is shared by all sessions of the + * workspace and persisted entries survive restarts; non-persisted entries + * live in handler memory only. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; + +export interface WorkspaceAddDirInput { + readonly path: string; + /** Persist to `.kimi-code/local.toml` (default true); false keeps the dir in handler memory only. */ + readonly persist?: boolean; +} + +export interface WorkspaceAdditionalDirsResult { + readonly projectRoot: string; + readonly configPath: string; + readonly additionalDirs: readonly string[]; + readonly persisted: boolean; +} + +export interface IWorkspaceDirs { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; + addDir(input: WorkspaceAddDirInput): Promise; + /** + * Union caller-provided dirs (session create/resume options, resolved + * against `baseDir`) into the shared in-memory set — never persisted. + */ + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise; + sessionInfo(): ISessionWorkspaceInfo; +} + +export const IWorkspaceDirs: ServiceIdentifier = + createDecorator('workspaceDirs'); diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts new file mode 100644 index 000000000..ada0a110e --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -0,0 +1,200 @@ +/** + * `workspaceDirs` domain (L3) — `IWorkspaceDirs` implementation. + * + * Holds the handler-shared additional-directory set as + * `fileDirs ∪ ephemeralDirs`: `fileDirs` is the project-local + * `.kimi-code/local.toml` set (loaded once per handler through + * `projectLocalConfig`, reloaded debounced when the fs watch sees the file + * change — including writes from OTHER processes), `ephemeralDirs` is the + * in-memory union of non-persisted `addDir` calls and caller-provided dirs + * from session create/resume options (it dies with the handler). Every + * mutation serializes on one tail queue; the change event fires only when + * the combined list actually changed. The set reaches every session of the + * handler through the `ISessionWorkspaceInfo` seed (`sessionInfo()`), a + * live read view over this service. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { + IWorkspaceDirs, + type WorkspaceAddDirInput, + type WorkspaceAdditionalDirsResult, +} from './workspaceDirs'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { + declare readonly _serviceBrand: undefined; + + private fileDirs: readonly string[] = []; + private ephemeralDirs: readonly string[] = []; + private projectRoot: string; + private configPath: string; + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private mutationTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.projectRoot = workspace.cwd; + this.configPath = ''; + this.ready = this.enqueue(() => this.reloadFromDisk()); + void this.ready.then(() => this.watchLocalToml()); + } + + get additionalDirs(): readonly string[] { + return [...new Set([...this.fileDirs, ...this.ephemeralDirs])]; + } + + addDir(input: WorkspaceAddDirInput): Promise { + return this.enqueue(() => this.applyAddDir(input)); + } + + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise { + if (dirs.length === 0) return Promise.resolve(); + return this.enqueue(async () => { + const resolved = await this.localConfig.resolveAdditionalDirs(baseDir, dirs); + if (this.unionEphemeral(resolved)) { + this.onDidChangeEmitter.fire(); + } + }); + } + + sessionInfo(): ISessionWorkspaceInfo { + const currentDirs = (): readonly string[] => this.additionalDirs; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get additionalDirs() { + return currentDirs(); + }, + }; + } + + private async applyAddDir(input: WorkspaceAddDirInput): Promise { + const persist = input.persist ?? true; + + if (persist) { + const persisted = await this.localConfig.appendAdditionalDir( + this.workspace.cwd, + input.path, + ); + this.projectRoot = persisted.projectRoot; + this.configPath = persisted.configPath; + const changed = this.setFileDirs(persisted.additionalDirs); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: persisted.projectRoot, + configPath: persisted.configPath, + additionalDirs: this.additionalDirs, + persisted: true, + }; + } + + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [ + input.path, + ]); + const changed = this.unionEphemeral(resolved); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: onDisk.projectRoot, + configPath: onDisk.configPath, + additionalDirs: this.additionalDirs, + persisted: false, + }; + } + + private async reloadFromDisk(): Promise { + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + if (this.setFileDirs(onDisk.additionalDirs)) { + this.onDidChangeEmitter.fire(); + } + } + + private setFileDirs(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.fileDirs = dirs; + return !sameStringList(before, this.additionalDirs); + } + + private unionEphemeral(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.ephemeralDirs = [...new Set([...this.ephemeralDirs, ...dirs])]; + return !sameStringList(before, this.additionalDirs); + } + + /** + * Watch the project root recursively, pruned to the `local.toml` + * candidate: watching the file directly never fires when its parent + * `.kimi-code` directory does not exist yet either. + */ + private watchLocalToml(): void { + if (this.configPath === '') return; + try { + const handle = this.fsWatch.watch(this.projectRoot, { + recursive: true, + ignored: subtreeWatchFilter(this.projectRoot, [this.configPath]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.enqueue(() => this.reloadFromDisk()).catch((error) => { + this.log.warn(`local.toml reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch project-local config ${this.configPath}: ${String(error)}`); + } + } + + private enqueue(work: () => Promise): Promise { + const run = this.mutationTail.then(work, work); + this.mutationTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} + +function sameStringList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts index 27904799a..a97aa1c9a 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts @@ -31,6 +31,11 @@ export type { SessionCloseReason, SessionCreateSource }; export interface CreateSessionOptions { readonly sessionId?: string; readonly workDir: string; + /** + * Caller-provided additional workspace directories, resolved against + * `workDir` and unioned into the handler's SHARED in-memory set (every + * session of this workspace sees them; never persisted). + */ readonly additionalDirs?: readonly string[]; readonly mainAgentBinding?: BindAgentInput; } @@ -45,8 +50,8 @@ export interface ForkSessionOptions { export interface ResumeSessionOptions { /** * Caller-provided additional workspace directories, re-resolved against the - * session workDir and merged over the workspace-local set for the resumed - * session's lifetime. + * session workDir and unioned into the handler's shared in-memory set (same + * semantics as `CreateSessionOptions.additionalDirs`). */ readonly additionalDirs?: readonly string[]; } diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index 5fe76f824..f0be47b3d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -16,8 +16,12 @@ * Every Session scope is also seeded with the handler's shared workspace * resources as pure-data read views (the injection contracts): * `sessionSkillCatalogData` / `sessionAgentProfileCatalogData` (the merged - * catalogs), `sessionInstructionsProvider` (the AGENTS.md snapshot), and - * `sessionMcpHandle` (the one shared MCP connection manager) — discovery, + * catalogs), `sessionInstructionsProvider` (the AGENTS.md snapshot), + * `sessionMcpHandle` (the one shared MCP connection manager), and + * `sessionWorkspaceInfo` (the shared additional-directory set — caller + * `additionalDirs` options union into it at materialization; the + * `workspaceDirs` service owns persistence and the `local.toml` watch) — + * discovery, * watching and connecting all live on the Workspace-scope services; session * consumers read the seeds and refresh off their change events. * Materializes the session's initial metadata on @@ -78,7 +82,6 @@ import { ISessionIndex, PARENT_SESSION_ID_KEY, } from '#/app/sessionIndex/sessionIndex'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { createHooks } from '#/hooks'; @@ -93,6 +96,7 @@ import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { sessionAgentProfileCatalogDataSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogData'; import { sessionInstructionsProviderSeed } from '#/session/sessionInstructions/instructionsProvider'; +import { sessionWorkspaceInfoSeed } from '#/session/workspaceInfo/workspaceInfo'; import { ISessionLifecycleHooks, sessionLifecycleHooksSeed, @@ -109,6 +113,7 @@ import { } from '#/wire/record'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; @@ -155,8 +160,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ICronTaskPersistence private readonly cronStore: ICronTaskPersistence, - @IProjectLocalConfigService - private readonly projectLocalConfig: IProjectLocalConfigService, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, @IWorkspaceSkillCatalog private readonly skillCatalog: IWorkspaceSkillCatalog, @@ -164,6 +167,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan private readonly agentProfileCatalog: IWorkspaceAgentProfileCatalog, @IWorkspaceInstructionsService private readonly instructions: IWorkspaceInstructionsService, @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, ) { super(); } @@ -213,12 +217,14 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan const sessionScope = sessionScopeOf(this.handlerScope, opts.sessionId); const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, opts.sessionId); const metaScope = sessionScope; - const localWorkspaceDirs = await this.projectLocalConfig.readAdditionalDirs(opts.workDir); - const callerAdditionalDirs = await this.projectLocalConfig.resolveAdditionalDirs( - opts.workDir, - opts.additionalDirs ?? [], - ); - const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; + // Caller-provided dirs join the handler's SHARED in-memory set (union + // across all sessions of this workspace, §6.1) — the workspace dirs + // service owns the local.toml set and its watch; sessions read the + // combined view through the `ISessionWorkspaceInfo` seed below. Await + // the initial local.toml load first so the ctx snapshot and the seed + // both start from the assembled set. + await this.workspaceDirs.ready; + await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []); const ctx: ISessionContext = { _serviceBrand: undefined, sessionId: opts.sessionId, @@ -226,7 +232,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan sessionDir, metaScope, cwd: opts.workDir, - additionalDirs, + additionalDirs: this.workspaceDirs.additionalDirs, scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; @@ -252,6 +258,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan ...sessionAgentProfileCatalogDataSeed(this.agentProfileCatalog.sessionData()), ...sessionInstructionsProviderSeed(this.instructions.sessionProvider()), ...sessionMcpHandleSeed(this.mcp.sessionHandle()), + ...sessionWorkspaceInfoSeed(this.workspaceDirs.sessionInfo()), ], }, ) as ISessionScopeHandle; diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 6d73215c9..91b2732fe 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -7,6 +7,8 @@ import { registerScopedService, } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; @@ -14,6 +16,7 @@ import { IEventService } from '#/app/event/event'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -27,6 +30,8 @@ import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/worksp import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { Error2, ErrorCodes } from '#/errors'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; @@ -34,6 +39,7 @@ import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; @@ -246,6 +252,13 @@ describe('WorkspaceLifecycleService', () => { ScopeActivation.OnScopeCreated, 'workspaceHandler', ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); registerScopedService( LifecycleScope.App, IHostFileSystem, @@ -305,6 +318,11 @@ describe('WorkspaceLifecycleService', () => { Promise.resolve([...dirs]), appendAdditionalDir: () => Promise.reject(new Error('not implemented')), } satisfies IProjectLocalConfigService), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 389b097c8..0a87d260e 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -25,6 +25,7 @@ import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceServic import { IAgentGoalService } from '#/agent/goal/goal'; import { AgentGoalService } from '#/agent/goal/goalService'; import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import { loadAgentsMdForRoots, type LoadedAgentsMd } from '#/agent/profile/context'; import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -1204,6 +1205,12 @@ export class AgentTestContext { ready: Promise.resolve(), connectionManager: new McpConnectionManager(), } satisfies ISessionMcpHandle); + reg.defineInstance(ISessionWorkspaceInfo, { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: [], + onDidChange: Event.None as Event, + } satisfies ISessionWorkspaceInfo); reg.defineInstance(IAgentLifecycleService, { _serviceBrand: undefined, onDidCreate: Event.None as Event, diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts new file mode 100644 index 000000000..84c181db2 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -0,0 +1,464 @@ +/** + * Scenario: workspace-level add-dir (the phase-3.5 behavior contract). + * + * Drives the REAL handler chain (WorkspaceLifecycleService → + * WorkspaceHandlerService) with the real `WorkspaceDirsService`, the real + * node-fs `FileProjectLocalConfigService`, the real fs watch service, and + * the real Session-scope `workspaceContext` view, and proves: + * - a persisted `addDir` writes `.kimi-code/local.toml` and refreshes every + * live session's view of the workspace; + * - a second session of the same workspace sees the dir immediately; + * - a handler rebuilt from scratch (simulated restart) keeps the persisted + * dir; + * - `persist: false` stays in handler memory and never touches the disk; + * - an external `local.toml` edit (another process) refreshes live session + * views through the fs watch. + * Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceDirs/workspaceDirs.test.ts`. + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, + type ISessionScopeHandle, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { + IProjectLocalConfigService, +} from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; +import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; + +import { stubLog } from '../../_base/log/stubs'; + +function workspaceCatalogStub(): IWorkspaceService { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const workspace: Workspace = workspaces.get(id) ?? { + id, + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + +function workspaceSkillCatalogStub(): IWorkspaceSkillCatalog { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + }), + } as unknown as IWorkspaceSkillCatalog; +} + +function workspaceAgentProfileCatalogStub(): IWorkspaceAgentProfileCatalog { + const data = { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: Event.None, + get: () => undefined, + getDefault: () => undefined, + list: () => [], + }; + return { + ...data, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => data, + } as unknown as IWorkspaceAgentProfileCatalog; +} + +function workspaceInstructionsStub(): IWorkspaceInstructionsService { + const provider = { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None, + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange: Event.None, + reload: () => Promise.resolve(), + sessionProvider: () => provider, + } as unknown as IWorkspaceInstructionsService; +} + +function workspaceMcpStub(): IWorkspaceMcpService { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + sessionHandle: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + get connectionManager(): never { + throw new Error('not implemented'); + }, + }), + } as unknown as IWorkspaceMcpService; +} + +describe('workspace add-dir (handler chain)', () => { + let hosts: ScopedTestHost[]; + let tmpRoots: string[]; + + beforeEach(() => { + _clearScopedRegistryForTests(); + hosts = []; + tmpRoots = []; + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Session, + ISessionWorkspaceContext, + SessionWorkspaceContextService, + ScopeActivation.OnScopeCreated, + 'workspaceContext', + ); + }); + + afterEach(async () => { + for (const host of hosts.splice(0)) { + host.dispose(); + } + await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tmpRoots.push(root); + return root; + } + + /** A project root with a `.git` marker so local.toml lands at the root. */ + async function makeProjectRoot(): Promise { + const root = await makeRoot('kimi-add-dir-proj-'); + await mkdir(join(root, '.git')); + return root; + } + + function buildHost(homeDir: string): ScopedTestHost { + const bootstrap = { + _serviceBrand: undefined, + homeDir, + osHomeDir: homeDir, + scope: (name: string) => name, + } as unknown as IBootstrapService; + const hostFs = new HostFileSystem(); + const host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrap), + stubPair(IHostEnvironment, { + _serviceBrand: undefined, + osKind: 'Linux', + homeDir, + ready: Promise.resolve(), + } as unknown as IHostEnvironment), + stubPair(IHostFileSystem, hostFs), + stubPair(IHostFsWatchService, new HostFsWatchService()), + stubPair(ILogService, stubLog()), + stubPair(IConfigService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(ITelemetryService, noopTelemetryService), + stubPair(IWorkspaceService, workspaceCatalogStub()), + stubPair(ISessionIndex, { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + } as unknown as ISessionIndex), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => Event.None, + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } as unknown as IEventService), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + } as unknown as ICronTaskPersistence), + stubPair( + IProjectLocalConfigService, + new FileProjectLocalConfigService(bootstrap, hostFs), + ), + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } as unknown as ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } as unknown as ISessionToolPolicy), + stubPair(IAgentLifecycleService, { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService), + stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()), + stubPair(IWorkspaceAgentProfileCatalog, workspaceAgentProfileCatalogStub()), + stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()), + stubPair(IWorkspaceMcpService, workspaceMcpStub()), + ]); + hosts.push(host); + return host; + } + + async function handlerFor( + host: ScopedTestHost, + root: string, + ): Promise<{ service: IWorkspaceHandlerService; dirs: IWorkspaceDirs }> { + const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); + return { + service: handler.accessor.get(IWorkspaceHandlerService), + dirs: handler.accessor.get(IWorkspaceDirs), + }; + } + + function dirsOf(handle: ISessionScopeHandle): readonly string[] { + return handle.accessor.get(ISessionWorkspaceContext).additionalDirs; + } + + it('persists addDir to local.toml and every session of the workspace sees the dir', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + const result = await dirs.addDir({ path: extra, persist: true }); + + expect(result.persisted).toBe(true); + expect(result.projectRoot).toBe(root); + expect(result.configPath).toBe(join(root, '.kimi-code', 'local.toml')); + expect(result.additionalDirs).toEqual([extra]); + // local.toml written on disk. + const toml = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf8'); + expect(toml).toContain('additional_dir'); + expect(toml).toContain(extra); + // The live session's view refreshed through the change event. + expect(dirsOf(s1)).toEqual([extra]); + // A second session of the same workspace sees it immediately. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('keeps the persisted dir across a handler rebuild (simulated restart)', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const first = buildHost(homeDir); + const firstHandler = await handlerFor(first, root); + await firstHandler.service.create({ sessionId: 's1', workDir: root }); + await firstHandler.dirs.addDir({ path: extra, persist: true }); + first.dispose(); + hosts = hosts.filter((h) => h !== first); + + const second = buildHost(homeDir); + const secondHandler = await handlerFor(second, root); + const s2 = await secondHandler.service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('persist: false stays in handler memory, never touches the disk, and is shared', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + + const result = await dirs.addDir({ path: extra, persist: false }); + + expect(result.persisted).toBe(false); + expect(result.additionalDirs).toEqual([extra]); + expect(dirsOf(s1)).toEqual([extra]); + // Nothing written: local.toml does not exist. + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + // The in-memory dir is shared with a second session of the workspace. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('refreshes live session views when another process edits local.toml', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + // External write (another process, an editor, `kimi` in a second CLI). + await mkdir(join(root, '.kimi-code'), { recursive: true }); + const writeLocalToml = () => + writeFile(join(root, '.kimi-code', 'local.toml'), `[workspace]\nadditional_dir = ["${extra}"]\n`); + await writeLocalToml(); + + // The chokidar watcher ignores files it finds during its initial scan + // (`ignoreInitial`), so a write landing inside that window is swallowed; + // rewrite while polling (slower than the 200ms reload debounce, so the + // debounce always gets a quiet window) until a `modify` event lands. + const deadline = Date.now() + 10_000; + while (!dirsOf(s1).includes(extra)) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for watch-driven local.toml reload'); + } + await writeLocalToml(); + await new Promise((resolve) => setTimeout(resolve, 400)); + } + }, 15_000); + + it('unions caller additionalDirs from create options into the shared set', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root, additionalDirs: [extra] }); + expect(dirsOf(s1)).toEqual([extra]); + // Caller dirs join the handler-shared set: a session created WITHOUT the + // option sees them too, and nothing was persisted. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index c4ff83a7e..1c88ee7a9 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -13,12 +13,15 @@ import { registerScopedService, } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; import type { Hooks } from '#/hooks'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { IEventService } from '#/app/event/event'; import { IAgentLifecycleService, @@ -27,6 +30,8 @@ import { import type { McpConnectionManager } from '#/agent/mcp/connection-manager'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IAgentPlanService } from '#/agent/plan/plan'; @@ -61,6 +66,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; function bootstrapStub(): IBootstrapService { return { @@ -475,6 +481,13 @@ describe('WorkspaceHandlerService', () => { ScopeActivation.OnScopeCreated, 'workspaceHandler', ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); registerScopedService( LifecycleScope.Session, ISessionExternalHooksService, @@ -527,6 +540,11 @@ describe('WorkspaceHandlerService', () => { getSecondaryModelWarning: () => undefined, } as ISessionSecondaryModelWarningService), stubPair(IProjectLocalConfigService, projectLocalConfigStub()), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, cronStoreStub()), ...extra, diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index a6e67aa98..ef39857e0 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -77,6 +77,8 @@ import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; @@ -176,6 +178,13 @@ describe('workspace resource sharing (handler chain)', () => { ScopeActivation.OnScopeCreated, 'workspaceMcp', ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService, ScopeActivation.OnScopeCreated, 'sessionSkillCatalog'); registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state'); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 21e596a1b..1315812c0 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -23,6 +23,8 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ApprovalHandler, QuestionHandler } from '#/events'; import type { + AddAdditionalDirInput, + AddAdditionalDirResult, BackgroundTaskInfo, ConfigDiagnostics, CreateSessionOptions, @@ -370,6 +372,11 @@ export abstract class SDKRpcClientBase { return rpc.getSessionWarnings({ sessionId: input.sessionId }); } + async addAdditionalDir(input: AddAdditionalDirInput): Promise { + const rpc = await this.getRpc(); + return rpc.addAdditionalDir({ sessionId: input.id, path: input.path, persist: input.persist }); + } + async startBtw(input: SessionIdRpcInput): Promise { const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 4ad6c34a3..46d60e84c 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -27,12 +27,12 @@ * every read behind its own initial load, so there is no ready trap here. * - `listSessions` / `createSession` / `renameSession` / `forkSession` / * `closeSession` / `resumeSession` / `reloadSession` / - * `updateSessionMetadata` → the session lifecycle + * `updateSessionMetadata` / `addAdditionalDir` → the session lifecycle * batch: `klient.global.sessions.list` plus the `klient.session(id)` * metadata mutations where the facade reaches, and the * `IWorkspaceLifecycleService` / handler chain / session-scope services through * {@link engineAccessor} where it does not (explicit session ids, resume, - * fork ids). The v1 `SessionSummary` / `SessionMeta` + * fork ids, the workspace-level add-dir surface). The v1 `SessionSummary` / `SessionMeta` * shapes are restored by the pure mapping layer in * `src/v2/session-mapper.ts`. `deleteSession` stays `not_implemented` — * the v2 engine has no session-deletion capability anywhere (tracked in @@ -184,6 +184,7 @@ import { ISkillDiscovery, ITelemetryService, IWorkspaceAliases, + IWorkspaceDirs, IWorkspaceHandlerService, IWorkspaceLifecycleService, closeSessionById, @@ -240,6 +241,8 @@ import { type UpdateSessionMetadataRpcInput, } from '#/rpc'; import type { + AddAdditionalDirInput, + AddAdditionalDirResult, BackgroundTaskInfo, CompactOptions, ConfigDiagnostics, @@ -1038,7 +1041,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async resumeSession(input: ResumeSessionInput): Promise { // v1 re-resolves caller-provided additional dirs on every resume and // merges them over the workspace-local set; the engine's resume options - // do the same while the session scope is materialized. Unlike v1, the v2 + // union them into the handler's shared in-memory set while the session + // scope is materialized. Unlike v1, the v2 // engine has no caller `mcpServers` channel on create/resume (caller // servers are an ACP-side concern to be designed separately). const handle = await resumeSessionById(this.engineAccessor, input.id, { @@ -1103,6 +1107,23 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { await this.klient.session(input.sessionId).update({ custom }); } + /** + * Through the session's handler (`IWorkspaceDirs`, workspace scope) — the + * workspace-level add-dir surface: `persist: true` (default) appends to the + * project-local `.kimi-code/local.toml`, `persist: false` joins the + * handler's shared in-memory set. The set is shared by every session of + * the workspace (a v1 `persist: false` dir was session-scoped and written + * into session metadata to survive a resume; the v2 handler keeps it for + * every session of the workspace until the process exits). Returns the + * same `{additionalDirs, projectRoot, configPath, persisted}` shape as v1. + */ + override async addAdditionalDir(input: AddAdditionalDirInput): Promise { + const handle = this.requireLiveSession(input.id); + return handle.accessor + .get(IWorkspaceDirs) + .addDir({ path: input.path, persist: input.persist }); + } + /** * Through `engineAccessor` (`ISessionExportService`, app scope) — the v2 * port of v1's export: same payload fields, same zip writer layout, same diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 5e1cd90f0..571c2355f 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -9,6 +9,8 @@ import { import { type ApprovalHandler, type Event, type QuestionHandler } from '#/events'; import type { SDKRpcClientBase } from '#/rpc'; import type { + AddAdditionalDirOptions, + AddAdditionalDirResult, BackgroundTaskInfo, CompactOptions, CreateGoalInput, @@ -156,6 +158,25 @@ export class Session { return this.rpc.getSessionWarnings({ sessionId: this.id }); } + async addAdditionalDir( + path: string, + options?: AddAdditionalDirOptions, + ): Promise { + this.ensureOpen(); + const normalized = normalizeRequiredString( + path, + 'Additional directory cannot be empty', + ErrorCodes.REQUEST_INVALID, + ); + const result = await this.rpc.addAdditionalDir({ + id: this.id, + path: normalized, + persist: options?.persist ?? true, + }); + this.summary = { ...this.requireSummary(), additionalDirs: result.additionalDirs }; + return result; + } + async startBtw(): Promise { this.ensureOpen(); return this.rpc.startBtw({ sessionId: this.id }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index cd640c0dc..0968d96a1 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -143,6 +143,18 @@ export interface ReloadSessionInput extends ResumeSessionInput { readonly forcePluginSessionStartReminder?: boolean; } +export interface AddAdditionalDirInput { + readonly id: string; + readonly path: string; + readonly persist: boolean; +} + +export interface AddAdditionalDirOptions { + /** When true, share the directory through workspace local config. When false, + * keep it scoped to this session while still restoring it on session resume. */ + readonly persist: boolean; +} + export interface ForkSessionInput { readonly id: string; readonly forkId?: string; @@ -248,6 +260,13 @@ export interface SessionSummary { readonly additionalDirs?: readonly string[]; } +export interface AddAdditionalDirResult { + readonly additionalDirs: readonly string[]; + readonly projectRoot: string; + readonly configPath: string; + readonly persisted: boolean; +} + export type ResumedSessionState = Pick; export interface ResumedSessionSummary extends SessionSummary, ResumedSessionState { } diff --git a/packages/node-sdk/test/session-context.test.ts b/packages/node-sdk/test/session-context.test.ts index e9fa04636..36f9564d0 100644 --- a/packages/node-sdk/test/session-context.test.ts +++ b/packages/node-sdk/test/session-context.test.ts @@ -19,12 +19,32 @@ import { import { TEST_IDENTITY } from './test-identity'; const tempDirs: string[] = []; +const toPosix = (path: string): string => path.replaceAll('\\', '/'); afterEach(async () => { await removeTempDirs(tempDirs); }); describe('Session context', () => { + it('restores a session-only additional directory after close and resume', async () => { + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-additional-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-additional-work-'); + const additionalDir = await makeTempDir(tempDirs, 'kimi-sdk-additional-dir-'); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_additional_resume', workDir }); + await session.addAdditionalDir(additionalDir, { persist: false }); + await session.close(); + + const resumed = await harness.resumeSession({ id: 'ses_additional_resume' }); + + expect(resumed.summary?.additionalDirs).toEqual([toPosix(additionalDir)]); + } finally { + await harness.close(); + } + }); + it('clears context without replacing the session', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-context-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-context-work-'); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 0575251da..0b7e4b8dd 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -1478,6 +1478,59 @@ describe('v1↔v2 session lifecycle parity', () => { await closeSessionPair(pair); } }); + + it('addAdditionalDir returns the same result for persist and non-persist', async () => { + const pair = await makeSessionParityPair(); + const persistedDir = await makeTempDir('kimi-sdk-parity-extra-persisted-'); + const sessionOnlyDir = await makeTempDir('kimi-sdk-parity-extra-session-'); + try { + await createOnBoth(pair, { id: 'session_parity_adddir' }); + // Both engines read and write the SAME workspace local config (shared + // workDir), so same-file mutations stay sequential — v1's write is not + // serialized against v2's read (same lesson as the plugin manager). + const v1Persisted = await pair.v1.addAdditionalDir({ + id: 'session_parity_adddir', + path: persistedDir, + persist: true, + }); + const v2Persisted = await pair.v2.addAdditionalDir({ + id: 'session_parity_adddir', + path: persistedDir, + persist: true, + }); + expect(v2Persisted).toEqual(v1Persisted); + expect(v1Persisted).toMatchObject({ + additionalDirs: [persistedDir], + projectRoot: pair.workDir, + configPath: join(pair.workDir, '.kimi-code', 'local.toml'), + persisted: true, + }); + const v1SessionOnly = await pair.v1.addAdditionalDir({ + id: 'session_parity_adddir', + path: sessionOnlyDir, + persist: false, + }); + const v2SessionOnly = await pair.v2.addAdditionalDir({ + id: 'session_parity_adddir', + path: sessionOnlyDir, + persist: false, + }); + expect(v2SessionOnly).toEqual(v1SessionOnly); + expect(v1SessionOnly).toMatchObject({ + additionalDirs: [persistedDir, sessionOnlyDir], + persisted: false, + }); + // v1 requires the active session on both engines. + await expect( + pair.v1.addAdditionalDir({ id: 'session_missing', path: persistedDir, persist: true }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.addAdditionalDir({ id: 'session_missing', path: persistedDir, persist: true }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + } + }); }); // ---------------------------------------------------------------------------