diff --git a/.changeset/add-dir-workspace.md b/.changeset/add-dir-workspace.md new file mode 100644 index 000000000..5761342c8 --- /dev/null +++ b/.changeset/add-dir-workspace.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add /add-dir workspace directory management with session-only and project-remembered additional directories. diff --git a/.gitignore b/.gitignore index 691c675b7..58fdb8953 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage/ plugins/cdn/ superpowers .worktrees/ +.kimi-code/local.toml diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..7121402a6 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -71,6 +71,14 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--add-dir ', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); @@ -119,6 +127,7 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + addDirs: raw['addDir'] as string[], }; onMain(opts); diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..2e0c33a42 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -11,6 +11,7 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + addDirs?: string[]; } export interface ValidatedOptions { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index f7cef067d..3eecc62b6 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -243,7 +243,10 @@ async function resolvePromptSession( `Session "${opts.session}" was created under a different directory.`, ); } - const session = await harness.resumeSession({ id: opts.session }); + const session = await harness.resumeSession({ + id: opts.session, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -267,7 +270,10 @@ async function resolvePromptSession( const sessions = await harness.listSessions({ workDir }); const previous = sessions[0]; if (previous !== undefined) { - const session = await harness.resumeSession({ id: previous.id }); + const session = await harness.resumeSession({ + id: previous.id, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -290,7 +296,12 @@ async function resolvePromptSession( } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await harness.createSession({ workDir, model, permission: 'auto' }); + const session = await harness.createSession({ + workDir, + model, + permission: 'auto', + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); installHeadlessHandlers(session); return { session, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index e5bdfef24..dcc4d3ca6 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -98,6 +98,7 @@ export async function runShell( const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { cliOptions: opts, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, workDir, 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..0c8543863 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,106 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import { nextTranscriptId } from '../utils/transcript-id'; +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'); +} + +function formatAddDirMessage(path: string, choice: AddDirChoice, configPath: string): string { + return choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}` + : `Added workspace directory:\n ${path}\n For this session only`; +} + +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(); + const message = formatAddDirMessage(path, choice, result.configPath); + await session.appendUserMessage(message); + host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + renderMode: 'plain', + content: message, + }); + 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 ed67da39c..a50c66262 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -45,6 +45,7 @@ import { import { handleGoalCommand } from './goal'; import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; import { handlePluginsCommand } from './plugins'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import { handleSwarmCommand } from './swarm'; @@ -66,6 +67,7 @@ export { handleLogoutCommand, } from './auth'; export { handleBtwCommand } from './btw'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, @@ -257,6 +259,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 464cc770d..21c223d6a 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 '@earendil-works/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', @@ -146,6 +237,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/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index c03444f9b..24098cd59 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -129,15 +129,22 @@ export class ChoicePickerComponent extends Container implements Focusable { const titleSuffix = searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, - this.opts.formatHint === undefined - ? currentTheme.fg('textMuted', ` ${hint}`) - : this.opts.formatHint(` ${hint}`), ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } if (this.opts.notice !== undefined) { - lines.push(currentTheme.fg('success', ` ${this.opts.notice}`)); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + lines.push(currentTheme.fg('success', ` ${noticeLine}`)); + } } lines.push(''); if (searchable && view.query.length > 0) { 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 ded2af648..8950e3bc0 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -359,6 +359,17 @@ export class CustomEditor extends Editor { } super.handleInput(normalized); + this.reopenSlashArgumentCompletionAfterInput(normalized); + } + + private reopenSlashArgumentCompletionAfterInput(data: string): void { + if (data !== '/') return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + if (!textBeforeCursor.startsWith('/')) return; + if (!textBeforeCursor.includes(' ')) return; + (this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void }) + .requestAutocomplete?.({ force: true, explicitTab: false }); } } 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 fc9dc43be..bcc6fdc34 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 @@ -1,5 +1,5 @@ import { readdirSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, @@ -20,6 +20,7 @@ export interface SlashAutocompleteCommand extends SlashCommand { interface FsMentionCandidate { readonly path: string; + readonly absolutePath: string; readonly isDirectory: boolean; } @@ -27,19 +28,24 @@ interface FsMentionCandidate { * Kimi wrapper around pi-tui's combined autocomplete provider. * * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available. While managed fd is downloading (or when it is unavailable), a - * small filesystem fallback keeps basic `@` file and folder completion usable. - * Ordinary path completion is still handled by pi-tui's readdir-backed path - * completer. This wrapper also keeps Kimi-specific slash-command guards. + * available and only the current working directory is involved. While managed fd + * is downloading, when it is unavailable, or when the session has additional + * roots, a small filesystem fallback keeps `@` file and folder completion usable + * across every root. Ordinary path completion is still handled by pi-tui's + * readdir-backed path completer. This wrapper also keeps Kimi-specific + * slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], ) { + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that // inner's argument completion can find commands by alias too. const expanded: SlashAutocompleteCommand[] = []; @@ -77,14 +83,24 @@ export class FileMentionProvider implements AutocompleteProvider { const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { - if (this.fdPath === null) { - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + if (this.fdPath === null || this.additionalDirs.length > 0) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { // If fd fails to spawn unexpectedly, keep @ completion usable. - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } } @@ -148,6 +164,11 @@ export class FileMentionProvider implements AutocompleteProvider { } } + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } + try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { @@ -180,13 +201,14 @@ function extractAtPrefix(text: string): string | null { function getFsMentionSuggestions( workDir: string, + additionalDirs: readonly string[], atPrefix: string, signal: AbortSignal, ): AutocompleteSuggestions | null { if (signal.aborted) return null; const query = atPrefix.slice(1); - const candidates = collectFsMentionCandidates(workDir, signal); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); if (candidates.length === 0 || signal.aborted) return null; const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); @@ -198,44 +220,69 @@ function getFsMentionSuggestions( }; } -function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] { - const result: FsMentionCandidate[] = []; - const stack = ['']; - - while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) { - if (signal.aborted) break; - const relativeDir = stack.pop() ?? ''; - const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir); - let entries; - try { - entries = readdirSync(absoluteDir, { withFileTypes: true }); - } catch { - continue; - } +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; + + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; + + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; + } - for (const entry of entries) { - if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break; - if (entry.name === '.git') continue; - - const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); - const isSymlink = entry.isSymbolicLink(); - let isDirectory = entry.isDirectory(); - if (!isDirectory && isSymlink) { - try { - isDirectory = statSync(join(workDir, relativePath)).isDirectory(); - } catch { - // Broken symlink or permission error — keep it as a file candidate. + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } } - } - result.push({ path: relativePath, isDirectory }); - if (isDirectory && !isSymlink) { - stack.push(relativePath); + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } } } } - return result; + return [...candidatesByAbsolutePath.values()]; } function rankFsMentionCandidates( @@ -285,7 +332,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { return { value, label, - description: valuePath, + description: candidate.absolutePath, }; } @@ -293,6 +340,52 @@ function normalizePath(path: string): string { return path.replaceAll('\\', '/'); } +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], + textBeforeCursor: string, +): Promise { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; + + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; + + return { + prefix: parsed.argumentPrefix, + items, + }; +} + +function parseSlashArgumentContext( + textBeforeCursor: string, + slashCommands: readonly SlashAutocompleteCommand[], +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; + } + + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; +} + +function findSlashCommand( + slashCommands: readonly SlashAutocompleteCommand[], + commandName: string, +): SlashAutocompleteCommand | undefined { + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + function shouldSuppressLeadingWhitespaceSlashPath( textBeforeCursor: string, force: boolean | undefined, diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index b0d1cc22d..03199b65a 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,4 @@ -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; @@ -11,6 +11,10 @@ import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + export interface AuthFlowHost { state: TUIState; session: Session | undefined; @@ -67,7 +71,7 @@ export class AuthFlowController { return; } - const session = await host.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, thinking: level, @@ -77,7 +81,11 @@ export class AuthFlowController { ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, - }); + }; + if (host.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...host.state.appState.additionalDirs]; + } + const session = await host.harness.createSession(options); await host.setSession(session); host.setAppState({ sessionId: session.id, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 330f9c7f1..4bd83f9bd 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -149,6 +149,7 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; readonly workDir: string; @@ -160,6 +161,14 @@ export interface KimiTUIStartupInput { type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto ? 'auto' @@ -169,6 +178,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { return { model: '', workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, @@ -334,6 +344,7 @@ export class KimiTUI { slashCommands, this.state.appState.workDir, this.fdPath, + this.state.appState.additionalDirs, ); this.state.editor.setAutocompleteProvider(provider); } @@ -557,12 +568,15 @@ export class KimiTUI { let session: Session | undefined; let shouldReplayHistory = false; const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; - const createSessionOptions: CreateSessionOptions = { + const createSessionOptions: MutableCreateSessionOptions = { workDir, model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, }; + if (this.state.appState.additionalDirs.length > 0) { + createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; + } try { if (isResumeStartup) { @@ -593,13 +607,19 @@ export class KimiTUI { `Session "${startup.sessionFlag}" was created under a different directory.`, ); } - session = await this.harness.resumeSession({ id: startup.sessionFlag }); + session = await this.harness.resumeSession({ + id: startup.sessionFlag, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { const sessions = await this.harness.listSessions({ workDir }); const target = sessions[0]; if (target !== undefined) { - session = await this.harness.resumeSession({ id: target.id }); + session = await this.harness.resumeSession({ + id: target.id, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { session = await this.harness.createSession(createSessionOptions); @@ -1039,6 +1059,9 @@ export class KimiTUI { setAppState(patch: Partial): void { if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); @@ -1048,6 +1071,7 @@ export class KimiTUI { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); } + if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); } @@ -1064,6 +1088,12 @@ export class KimiTUI { this.state.ui.requestRender(); } + private syncAdditionalDirs(session: Session): void { + const additionalDirs = session.summary?.additionalDirs ?? []; + if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; + this.setAppState({ additionalDirs: [...additionalDirs] }); + } + // ========================================================================= // Session Runtime // ========================================================================= @@ -1080,14 +1110,18 @@ export class KimiTUI { if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } - return this.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, thinking: this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, - }); + }; + if (this.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...this.state.appState.additionalDirs]; + } + return this.harness.createSession(options); } async setSession(session: Session): Promise { @@ -1096,6 +1130,7 @@ export class KimiTUI { this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); + this.syncAdditionalDirs(session); } async syncRuntimeState(session: Session = this.requireSession()): Promise { @@ -1113,6 +1148,7 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.syncAdditionalDirs(session); } // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..b8249e92c 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -26,6 +26,7 @@ export interface BannerState { export interface AppState { model: string; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index e14629e01..e7fec7218 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,6 +41,7 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); @@ -307,6 +308,16 @@ describe('CLI options parsing', () => { }); }); + describe('--add-dir', () => { + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); + }); + }); + describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { let upgradeCalls = 0; @@ -364,7 +375,6 @@ describe('CLI options parsing', () => { '--print', '--wire', '--agent=default', - '--add-dir=/', '--raw-model', '--config-file=x', '--quiet', diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index a3620aa35..bc7c59989 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -135,6 +135,7 @@ function opts(overrides: Partial[0]> = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + addDirs: [], ...overrides, }; } @@ -205,6 +206,7 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'k2', permission: 'auto', + additionalDirs: undefined, }); expect(mocks.session.setPermission).not.toHaveBeenCalled(); expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -242,12 +244,27 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'kimi-code/k2.5', permission: 'auto', + additionalDirs: undefined, }); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ model: 'kimi-code/k2.5' }), ); }); + it('passes the CLI additional directory when creating a fresh prompt session', async () => { + await runPrompt(opts({ addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'k2', + permission: 'auto', + additionalDirs: ['../shared', '/tmp/extra'], + }); + }); + it('tracks first launch in prompt mode before harness construction can create the device id', async () => { mocks.harnessCreatesDeviceIdOnConstruction = true; const createdHomes = new Set(); @@ -442,6 +459,23 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when resuming a concrete session', async () => { + await runPrompt( + opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }), + '1.2.3-test', + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }, + ); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_existing', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -567,6 +601,19 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when continuing the previous session', async () => { + await runPrompt(opts({ continue: true, addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_previous', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index bab4fb152..106ef0db8 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -181,6 +181,7 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -219,6 +220,7 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], tuiConfig: { theme: 'dark', editorCommand: null, 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..31b9cf68f --- /dev/null +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -0,0 +1,213 @@ +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, + })), + appendUserMessage: vi.fn(async () => {}), + }; + 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(session.appendUserMessage).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n For this session only', + ); + expect(host.appendTranscriptEntry).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'user', + renderMode: 'plain', + content: 'Added workspace directory:\n ../shared\n For this session only', + }), + ); + 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', + ); + }); + }); + + 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 }); + }); + expect(session.appendUserMessage).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + ); + expect(host.appendTranscriptEntry).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'user', + renderMode: 'plain', + content: 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + }), + ); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + 'success', + ); + }); + }); + + 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(session.appendUserMessage).not.toHaveBeenCalled(); + expect(host.appendTranscriptEntry).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(session.appendUserMessage).not.toHaveBeenCalled(); + expect(host.appendTranscriptEntry).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 e25a1b984..10569be53 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -39,6 +39,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', @@ -88,6 +93,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', @@ -121,6 +131,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/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ab0878d6b..4045e3a06 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -34,6 +34,7 @@ function setDanceView(colored: boolean, phase: number): void { const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index cc3a2ff21..a910c2481 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -12,6 +12,7 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index fa30293cc..56cf7c5d0 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -7,6 +7,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { @@ -73,8 +74,39 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); +describe('CustomEditor slash argument completion refresh', () => { + it('reopens /add-dir directory completions after tab completion and entering slash', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => + prefix === '/' ? [{ value: '/tmp/shared/', label: 'shared/' }] : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); + + editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); +}); + describe('CustomEditor slash menu description wrapping', () => { - // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => { @@ -133,8 +165,8 @@ describe('CustomEditor Kitty key release handling', () => { }); describe('CustomEditor paste marker expansion', () => { - const PASTE_START = '\x1b[200~'; - const PASTE_END = '\x1b[201~'; + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; function simulateLargePaste(editor: CustomEditor, content: string): void { editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); @@ -199,7 +231,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\x16'); + editor.handleInput('\u0016'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); @@ -243,7 +275,7 @@ describe('CustomEditor paste marker expansion', () => { // Split: PASTE_START in chunk 1, paste-end split across chunk 2 and 3 editor.handleInput(`${PASTE_START}data`); - editor.handleInput('\x1b[20'); + editor.handleInput('\u001B[20'); editor.handleInput('1~'); expect(editor.getText()).toContain(longText); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b898ec89c..ab0588632 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -49,17 +49,43 @@ const HELP_FULL_COMMAND = { description: 'Show help', }; +const ADD_DIR_COMMAND = { + name: 'add-dir', + description: 'Add or list an additional workspace directory', + getArgumentCompletions: (prefix: string) => + prefix === '/' + ? [ + { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, + ] + : null, +}; + describe('FileMentionProvider', () => { let workDir: string; + let extraDirs: string[]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-')); + extraDirs = []; }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); + for (const extraDir of extraDirs) { + rmSync(extraDir, { recursive: true, force: true }); + } }); + function createExtraDir(): string { + const extraDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-extra-')); + extraDirs.push(extraDir); + return extraDir; + } + it('returns null when there is no completable prefix', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); @@ -82,6 +108,20 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(completedLine).toBe('/add-dir '); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + it('searches slash command aliases and displays aliases in the command label', async () => { const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); const line = '/clear'; @@ -229,6 +269,54 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); + + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts')}`, + ); + }); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts'), + ); + expect(overlapItems).toHaveLength(1); + }); + it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { writeFileSync(join(workDir, 'README.md'), 'readme'); const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index a9cc544fe..62f75a8d0 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -12,6 +12,7 @@ function baseState(overrides: Partial = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index fb1cfd5fe..f8255cddf 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -22,6 +22,7 @@ function baseState(overrides: Partial = {}): AppState { return { model: 'k2', workDir: '/tmp', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index d04c9c279..068761f7c 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -13,6 +13,7 @@ function baseState(overrides: Partial = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 8be57e91c..a67715f2b 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -8,6 +8,7 @@ function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/kimi-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, @@ -61,6 +62,7 @@ describe('createTUIState', () => { // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); expect(state.appState.model).toBe('test-model'); + expect(state.appState.additionalDirs).toEqual([]); expect(state.appState.sessionId).toBe('sess-1'); expect(state.startupState).toBe('pending'); diff --git a/dev_docs/add-dir-followup-todo.md b/dev_docs/add-dir-followup-todo.md new file mode 100644 index 000000000..2a28da5af --- /dev/null +++ b/dev_docs/add-dir-followup-todo.md @@ -0,0 +1,115 @@ +# add-dir 后续 TODO + +本文记录 `/add-dir` 相关功能的后续待办。前置调研见 `dev_docs/add-dir-local-command-stdout-research.md`。 + +## TODO 1:包装 ``,不进入消息流 + +### 目标 + +`/add-dir` 成功后,向当前 session 插入一条 user 角色的 `` 消息,但: + +- 不启动 turn +- 不立刻发给 AI +- 不出现在 live 消息流 +- 不出现在 resume 后的 transcript +- 仍然进入模型上下文 + +### 需要做的事 + +- 新增 core RPC 方法,例如 `appendLocalCommandStdout` +- 在 core agent context 中实现同名方法 +- 内容包装为: + +```xml + +... + +``` + +- origin 使用内部 injection 类型,例如: + +```ts +{ kind: 'injection', variant: 'local-command-stdout' } +``` + +- `/add-dir` 成功后调用该方法 +- 移除当前临时加入的 transcript 可见入口 +- `--add-dir` / resume flag 不插入该消息 + +### 验收标准 + +- `/add-dir` 成功后写入一条 user role record +- record 内容为 `...` +- origin 为内部 injection 类型 +- TUI transcript 不显示该消息 +- resume 后 transcript 不显示该消息 +- 下一轮用户发消息时,该记录会作为上下文发送给模型 + +## TODO 2:slash 文件补全优化,支持连续 Tab 显示补全列表 + +### 目标 + +优化 slash command 中的文件/路径补全体验,特别是 `/add-dir ` 这类场景。 + +当前问题: + +- 文件补全不支持连续 Tab 显示补全列表 +- 用户需要记住路径或手动输入完整路径 +- 补全交互不如普通 shell 顺手 + +### 需要做的事 + +- 梳理当前 slash command 的文件补全入口 +- 确认补全逻辑是复用 file mention provider,还是独立实现 +- 支持连续 Tab 触发补全列表展示 +- 支持目录/文件候选展示 +- 支持选择后回填到输入框 +- 处理目录结尾 `/` 与继续补全子目录 + +### 验收标准 + +- 输入 `/add-dir ` 后按 Tab 可以显示候选列表 +- 连续按 Tab 可以浏览/切换候选 +- 选择目录后可以继续补全子目录 +- 选择文件/目录后正确回填 +- 不影响普通 `@` 文件提及补全 + +## TODO 3:支持显示 hint + +### 目标 + +slash command / 补全列表中支持显示 hint,帮助用户理解每个选项的含义。 + +当前问题: + +- 补全项缺少说明 +- 用户无法从列表中判断选项作用 +- 对 `/add-dir` 这种涉及 session-only / remember 的交互尤其需要 hint + +### 需要做的事 + +- 梳理当前补全/选择组件的数据结构 +- 为候选项增加 hint 字段 +- UI 支持展示 hint +- 对 `/add-dir` 的选项补充明确 hint,例如: + - `Yes, for this session` → session-only,不写入 local config + - `Yes, and remember this directory` → 写入 `.kimi-code/local.toml` + - `No` → 取消添加 + +### 验收标准 + +- slash command 选项可以展示 hint +- 补全列表可以展示 hint +- hint 文案清晰,不超出 UI 宽度 +- 不影响现有选择/确认交互 + +## 优先级建议 + +1. TODO 1:`` 包装与隐藏 + 这是当前 `/add-dir` 行为收尾,依赖调研结论,优先级最高。 + +2. TODO 3:hint 显示 + 与 `/add-dir` 选择项体验直接相关,可以较早做。 + +3. TODO 2:连续 Tab 文件补全 + 依赖补全组件梳理,改动可能更大,可以放在 TODO 1 / TODO 3 之后。 diff --git a/dev_docs/add-dir-local-command-stdout-research.md b/dev_docs/add-dir-local-command-stdout-research.md new file mode 100644 index 000000000..f6f15a1db --- /dev/null +++ b/dev_docs/add-dir-local-command-stdout-research.md @@ -0,0 +1,280 @@ +# add-dir / local-command-stdout 调研记录 + +本文记录 `/add-dir`、`--add-dir`、``、bash mode 输出在参考实现中的行为,以及它们对 Kimi Code 当前实现的影响。 + +## 1. 背景 + +Kimi Code 当前正在支持 workspace additional dirs: + +- `--add-dir `:启动/resume 时添加额外 workspace 目录 +- `/add-dir `:在 TUI 中手动添加额外 workspace 目录 + +当前已经修复/确认的行为: + +- `--add-dir` 在 resume / continue 场景下也会传入并合并到 session additionalDirs +- caller 传入的相对路径按 `workDir` 解析,而不是 projectRoot +- additional dirs 默认 session-only,不写入 `.kimi-code/local.toml` +- additional dirs 的 `AGENTS.md` 默认不加载进上下文,只保留目录 listing + +接下来要确定的是:`/add-dir` 成功后应该插入什么形式的消息。 + +## 2. 参考实现里的 `/add-dir` 行为 + +参考实现中,`/add-dir` 是 `local-jsx` 类型的 slash command。 + +执行成功后,它不会插入 `system-reminder`,而是通过 local slash command 的输出机制插入一条 user 角色消息,内容用 `` 包裹。 + +示例: + +```xml + +Added /path/to/dir as a working directory for this session · /permissions to manage + +``` + +如果是 remember 模式,内容类似: + +```xml + +Added /path/to/dir as a working directory and saved to local settings · /permissions to manage + +``` + +关键点: + +- 不是 `system-reminder` +- 不是 assistant 消息 +- 是 user 角色的命令输出消息 +- 不会立刻触发模型回复,等价于 `shouldQuery: false` +- 会进入历史,下一轮用户发消息时一起发给模型 + +## 3. 参考实现里的 `--add-dir` / resume 行为 + +参考实现中,`--add-dir` 在启动或 resume 时: + +- 会初始化额外目录状态 +- 会把目录加入权限上下文 +- 会影响 system prompt 里的额外目录展示 +- 但**不会**插入 `` +- 也**不会**插入 `system-reminder` + +也就是说,参考实现只在**手动 `/add-dir`** 时插入命令输出消息;`--add-dir` 只是状态初始化。 + +另外,参考实现的 resume 不会恢复会话中通过 `/add-dir` 临时加入的 session-only 目录;只有 resume 命令行再次带 `--add-dir` 时才会重新应用。 + +Kimi Code 当前已经实现了 resume 时传入 `--add-dir`,这一点比参考实现更完整。 + +## 4. `` 的用途 + +`` 是“本地 slash command 输出”的通用包装。 + +典型场景包括: + +1. 本地配置/状态命令 + - `/add-dir` + - `/config` + - `/status` + - `/doctor` + +2. 本地信息命令 + - `/cost` + - `/voice` + - `/usage` + +3. 本地会话操作命令 + - `/compact` + - `/clear` + - `/btw` + +4. fork 子代理命令结果 + +5. bridge / remote 不支持的命令拒绝消息 + +错误输出对应另一个 tag: + +```xml + +``` + +## 5. `` 的语义 + +它在参考实现里有几个重要语义: + +- 表示命令输出,不是用户自然语言输入 +- 默认会进入模型上下文 +- 不会立刻触发模型回复 +- UI 会识别并做专门渲染 +- title / rewind / resubmit / prompt 计数等逻辑会把它从“真实用户输入”里排除 +- 但它仍然作为历史发给模型 + +需要注意:这些功能的“排除”不是从模型上下文删除,而是不把它当作“用户主动输入的一句话”。 + +## 6. bash mode 的对比 + +参考实现里 bash mode(例如 `!ls`)不使用 ``,而是使用专门的 bash tag。 + +bash 输入: + +```xml +ls +``` + +bash 输出: + +```xml +... +... +``` + +同时还有一条 caveat: + +```xml + +... + +``` + +bash 行为: + +- 输入和输出都是 user 角色消息 +- caveat 是 user 消息,但带 meta 标记 +- `shouldQuery: false` +- 执行完只 append 到上下文,本轮不请求模型 +- 下一轮用户正常输入时,这些 bash 消息会作为历史一起发给模型 + +结论: + +| 场景 | tag | +|---|---| +| slash command 输出 | `` | +| slash command 错误 | `` | +| bash 输入 | `` | +| bash 输出 | `` | +| bash 错误 | `` | + +Kimi Code 当前要处理的是 slash command 输出,所以应该用 ``,而不是 bash tag。 + +## 7. `` 是否进入模型上下文 + +默认会。 + +参考实现的 API 规范化逻辑会把 local command 输出纳入 API 对话,并与相邻 user 消息合并。 + +原因是:模型需要能在后续轮次引用之前的命令输出。 + +但它不是立刻发送: + +```text +执行 slash command +↓ +插入 +↓ +本轮不主动请求模型 +↓ +用户下一条正常消息 +↓ +命令输出作为历史一起发送 +``` + +## 8. title / rewind / resubmit / prompt 计数的影响 + +这些功能会跳过 ``,但只是从“真实用户输入”逻辑里跳过,不是从模型上下文删除。 + +### title + +生成会话标题时,不会用命令输出当标题。 + +例如不会用: + +```text +Added ../shared as a working directory +``` + +作为会话标题。 + +但消息仍在历史里。 + +### rewind + +rewind 选择器会跳过命令输出,因为它不是用户主动输入的 prompt。 + +但如果用户 rewind 到更早的时间点,这之后的命令输出也会随历史一起被移除。 + +### resubmit + +resubmit 不会把命令输出当作用户输入重新提交。 + +但如果 resubmit 某个真实 prompt,历史里已有的命令输出仍然会作为上下文一起发送。 + +### prompt 计数 + +统计用户 prompt 数量时,会跳过 terminal output。 + +也就是说: + +```text +普通用户输入:算一个 prompt +:不算 +``` + +但这只是计数,不影响上下文。 + +## 9. 对 Kimi Code 的设计建议 + +当前需求是: + +- `/add-dir` 成功后插入一条 `` +- 不插入 `system-reminder` +- 不立刻发给 AI +- 不出现在 live 消息流 +- 不出现在 resume 后的用户可见 transcript +- 但仍然进入模型上下文 + +这个需求和参考实现不完全一致。参考实现里 `` 通常会在 transcript / resume 里出现;而当前需求要求它对用户不可见。 + +因此建议设计: + +1. 新增一个 core RPC 方法,例如 `appendLocalCommandStdout` +2. 在 core agent context 里实现同名方法 +3. 内容包成: + +```xml + +... + +``` + +4. role 用 `user` +5. origin 用内部 injection 类型,例如: + +```ts +{ kind: 'injection', variant: 'local-command-stdout' } +``` + +6. 不启动 turn +7. 不 append 到 TUI transcript +8. `--add-dir` / resume flag 不插入这条消息,只有手动 `/add-dir` 插入 + +这样做的效果: + +- 用户在 live transcript 里看不到它 +- resume 后 transcript 也看不到 +- 不会作为真实用户输入参与 undo / title / resubmit / prompt 计数 +- 但仍在历史 records 里 +- 下一轮用户发消息时,会作为上下文发给模型 + +## 10. 待确认点 + +实现前需要确认: + +1. `variant` 名称用 `local-command-stdout` 还是 `add-dir` +2. `/add-dir` 成功后是否保留 `showStatus` 的瞬时反馈 +3. 这条消息是否需要写入 records(建议写入,否则 resume 后模型也看不到) +4. 是否需要覆盖 export / replay 的隐藏行为测试 + +当前推荐: + +- `variant: 'local-command-stdout'` +- 保留 `showStatus` 作为瞬时反馈,但不写入 transcript +- 写入 records,确保模型后续可见 +- 增加 runtime 测试,确认 record 是 user 角色、内容为 ``、origin 为内部 injection diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 392c1d4ea..cfb913bf5 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { join } from 'pathe'; +import { normalizeAdditionalDirs } from '../config'; import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; import type { Logger } from '#/logging/types'; @@ -89,6 +90,7 @@ export interface AgentOptions { readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly experimentalFlags?: ExperimentalFlagResolver; readonly replay?: ReplayBuilderOptions; + readonly additionalDirs?: readonly string[]; } export class Agent { @@ -132,6 +134,7 @@ export class Agent { readonly goal: GoalMode; readonly replayBuilder: ReplayBuilder; + private additionalDirs: readonly string[]; private lastLlmConfigLogSignature?: string; constructor(options: AgentOptions) { @@ -150,6 +153,7 @@ export class Agent { this.log = options.log ?? log; this.telemetry = options.telemetry ?? noopTelemetryClient; this.experimentalFlags = options.experimentalFlags ?? new FlagResolver(); + this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []); this.blobStore = options.homedir ? new BlobStore({ blobsDir: join(options.homedir, 'blobs') }) @@ -191,6 +195,17 @@ export class Agent { this._kaos = kaos; } + getAdditionalDirs(): readonly string[] { + return this.additionalDirs; + } + + setAdditionalDirs(additionalDirs: readonly string[]): void { + this.additionalDirs = normalizeAdditionalDirs(additionalDirs); + if (this.config.hasProvider) { + this.tools.initializeBuiltinTools(); + } + } + get generate(): typeof generate { return async (provider, systemPrompt, tools, history, callbacks, options) => { if (options?.auth !== undefined) { @@ -288,6 +303,7 @@ export class Agent { skills: this.skills?.registry, cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, + additionalDirsInfo: context?.additionalDirsInfo, }); this.config.update({ profileName: profile.name, systemPrompt }); this.tools.setActiveTools(profile.tools); @@ -318,6 +334,9 @@ export class Agent { this.telemetry.track('input_steer', { parts: payload.input.length }); this.turn.steer(payload.input); }, + appendUserMessage: (payload) => { + this.context.appendUserMessage(payload.input); + }, cancel: (payload) => { if (this.turn.hasActiveTurn) { this.telemetry.track('cancel', { from: 'streaming' }); diff --git a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts index 14c72f351..8cb6fffee 100644 --- a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts +++ b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts @@ -1,5 +1,5 @@ import type { Agent } from '../..'; -import { isWithinDirectory } from '../../../tools/policies/path-access'; +import { isWithinWorkspace } from '../../../tools/policies/path-access'; import { findGitWorkTreeMarker } from '../../../tools/support/git-worktree'; import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types'; import { writeFileAccesses } from './file-access-ask'; @@ -19,7 +19,15 @@ export class GitCwdWriteApprovePermissionPolicy implements PermissionPolicy { const writeAccesses = writeFileAccesses(context); if (writeAccesses.length === 0) return; - if (!writeAccesses.every((access) => isWithinDirectory(access.path, cwd, 'posix'))) { + if ( + !writeAccesses.every((access) => + isWithinWorkspace( + access.path, + { workspaceDir: cwd, additionalDirs: this.agent.getAdditionalDirs() }, + 'posix', + ), + ) + ) { return; } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index b80a87f31..91a8c9012 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -367,7 +367,7 @@ export class ToolManager { const workspace = extendWorkspaceWithSkillRoots( { workspaceDir: cwd, - additionalDirs: [], + additionalDirs: this.agent.getAdditionalDirs(), }, this.agent.skills?.registry.getSkillRoots() ?? [], ); diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index 41e5ca174..ac97d1221 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -4,3 +4,4 @@ export * from './resolve'; export * from './schema'; export * from './toml'; export * from './env-model'; +export * from './workspace-local'; diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts new file mode 100644 index 000000000..499529c9d --- /dev/null +++ b/packages/agent-core/src/config/workspace-local.ts @@ -0,0 +1,313 @@ +import { mkdir, readFile } from 'node:fs/promises'; + +import type { Kaos } from '@moonshot-ai/kaos'; +import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import { z } from 'zod'; + +import { ErrorCodes, KimiError } from '#/errors'; +import { writeFileAtomicDurable } from '#/utils/fs'; + +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; + +const WorkspaceLocalTomlSchema = z.object({ + workspace: z + .object({ + additional_dir: z.array(z.string()), + }) + .optional(), +}); + +type WorkspaceLocalToml = z.infer; + +export interface WorkspaceAdditionalDirsLoadResult { + readonly projectRoot: string; + readonly configPath: string; + readonly additionalDirs: readonly string[]; + readonly warning?: string; +} + +export type WorkspaceLocalConfig = WorkspaceAdditionalDirsLoadResult; + +interface WorkspaceLocalTomlFile { + readonly raw: Record; + readonly parsed: WorkspaceLocalToml; +} + +export async function loadWorkspaceLocalConfig( + kaos: Kaos, + workDir: string, +): Promise { + const projectRoot = await findProjectRoot(kaos, workDir); + const configPath = getWorkspaceLocalConfigPath(projectRoot); + const file = await readWorkspaceLocalToml(configPath); + + const additionalDirs = file?.parsed.workspace?.additional_dir; + if (additionalDirs === undefined) { + return { projectRoot, configPath, additionalDirs: [] }; + } + + return { + projectRoot, + configPath, + additionalDirs: await resolveAdditionalDirs(kaos, projectRoot, additionalDirs), + }; +} + +export async function readWorkspaceAdditionalDirs( + kaos: Kaos, + workDir: string, +): Promise { + return loadWorkspaceLocalConfig(kaos, workDir); +} + +export async function resolveWorkspaceAdditionalDirs( + kaos: Kaos, + projectRoot: string, + additionalDirs: readonly string[], +): Promise { + return resolveAdditionalDirs(kaos, projectRoot, additionalDirs); +} + +export async function appendWorkspaceAdditionalDir( + kaos: Kaos, + workDir: string, + inputPath: string, + _currentAdditionalDirs: readonly string[], +): Promise { + const projectRoot = await findProjectRoot(kaos, workDir); + const configPath = getWorkspaceLocalConfigPath(projectRoot); + const additionalDir = await resolveAdditionalDir(kaos, projectRoot, inputPath); + const file = (await readWorkspaceLocalToml(configPath)) ?? { raw: {}, parsed: {} }; + const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? []; + const fileExistingDirs = resolveExistingAdditionalDirs(kaos, projectRoot, fileAdditionalDirs); + + if (hasSameAdditionalDir(kaos, fileExistingDirs, additionalDir)) { + return { projectRoot, configPath, additionalDirs: fileExistingDirs }; + } + + const workspace = cloneRecord(file.raw['workspace']); + workspace['additional_dir'] = [...fileExistingDirs, additionalDir]; + file.raw['workspace'] = workspace; + + await mkdir(dirname(configPath), { recursive: true, mode: 0o700 }); + await writeFileAtomicDurable(configPath, `${stringifyToml(file.raw)}\n`); + + return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] }; +} + +export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] { + const seen = new Set(); + const normalizedDirs: string[] = []; + + for (const additionalDir of additionalDirs) { + const normalized = normalize(additionalDir); + if (seen.has(normalized)) continue; + seen.add(normalized); + normalizedDirs.push(normalized); + } + + return normalizedDirs; +} + +function getWorkspaceLocalConfigPath(projectRoot: string): string { + return join(projectRoot, '.kimi-code', 'local.toml'); +} + +async function findProjectRoot(kaos: Kaos, workDir: string): Promise { + const initial = resolveWorkDir(kaos, workDir); + let current = initial; + + for (;;) { + if (await pathExists(kaos, join(current, '.git'))) return current; + const parent = dirname(current); + if (parent === current) return initial; + current = parent; + } +} + +function resolveWorkDir(kaos: Kaos, workDir: string): string { + return isAbsolute(workDir) ? kaos.normpath(workDir) : resolve(kaos.getcwd(), workDir); +} + +async function readWorkspaceLocalToml(configPath: string): Promise { + let text: string; + try { + text = await readFile(configPath, 'utf-8'); + } catch (error: unknown) { + if (isPathMissing(error)) return undefined; + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Failed to read ${configPath}: ${describeError(error)}`, + { cause: error }, + ); + } + + if (text.trim().length === 0) return { raw: {}, parsed: {} }; + + let raw: unknown; + try { + raw = parseToml(text); + } catch (error: unknown) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid TOML in ${configPath}: ${describeError(error)}`, + { cause: error }, + ); + } + + if (!isPlainObject(raw)) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid workspace local config in ${configPath}`); + } + + return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) }; +} + +function parseWorkspaceLocalToml(raw: Record): WorkspaceLocalToml { + try { + return WorkspaceLocalTomlSchema.parse(raw); + } catch (error: unknown) { + if (error instanceof z.ZodError) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, describeWorkspaceLocalValidationError(error), { + cause: error, + }); + } + throw error; + } +} + +function describeWorkspaceLocalValidationError(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') { + return 'workspace.additional_dir must be an array of strings'; + } + if (issue?.path[0] === 'workspace') return 'workspace must be a table'; + return `Invalid workspace local config: ${error.message}`; +} + +async function resolveAdditionalDirs( + kaos: Kaos, + projectRoot: string, + additionalDirs: readonly string[], +): Promise { + const resolvedDirs: string[] = []; + + for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) { + const resolvedDir = await resolveAdditionalDir(kaos, projectRoot, additionalDir); + if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue; + resolvedDirs.push(resolvedDir); + } + + return resolvedDirs; +} + +function resolveExistingAdditionalDirs( + kaos: Kaos, + projectRoot: string, + additionalDirs: readonly string[], +): string[] { + const resolvedDirs: string[] = []; + + for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) { + const resolvedDir = resolvePath(projectRoot, additionalDir); + if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue; + resolvedDirs.push(resolvedDir); + } + + return resolvedDirs; +} + +async function resolveAdditionalDir( + kaos: Kaos, + projectRoot: string, + additionalDir: string, +): Promise { + const normalizedInput = normalizeAdditionalDirInput(additionalDir); + const resolvedDir = resolvePath(projectRoot, normalizedInput); + await assertDirectory(kaos, resolvedDir); + return resolvedDir; +} + +function normalizeAdditionalDirInput(additionalDir: string): string { + if (typeof additionalDir !== 'string') { + throw new KimiError(ErrorCodes.CONFIG_INVALID, 'workspace.additional_dir must be an array of strings'); + } + const trimmed = additionalDir.trim(); + if (trimmed.length === 0) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'workspace.additional_dir must exist and be a directory', + ); + } + return normalize(trimmed); +} + +function resolvePath(projectRoot: string, additionalDir: string): string { + return isAbsolute(additionalDir) ? normalize(additionalDir) : resolve(projectRoot, additionalDir); +} + +function hasSameAdditionalDir(kaos: Kaos, dirs: readonly string[], target: string): boolean { + const normalizedTarget = normalizeForCompare(kaos, target); + return dirs.some((dir) => normalizeForCompare(kaos, dir) === normalizedTarget); +} + +function normalizeForCompare(kaos: Kaos, filePath: string): string { + return kaos.normpath(filePath); +} + +async function assertDirectory(kaos: Kaos, filePath: string): Promise { + try { + const stat = await kaos.stat(filePath); + if ((stat.stMode & S_IFMT) === S_IFDIR) return; + } catch (error: unknown) { + if (isPathMissing(error)) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'workspace.additional_dir must exist and be a directory', + ); + } + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Failed to stat ${filePath}: ${describeError(error)}`, + { cause: error }, + ); + } + + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'workspace.additional_dir must exist and be a directory', + ); +} + +async function pathExists(kaos: Kaos, filePath: string): Promise { + try { + await kaos.stat(filePath); + return true; + } catch { + return false; + } +} + +function cloneRecord(value: unknown): Record { + if (!isPlainObject(value)) return {}; + return JSON.parse(JSON.stringify(value)) as Record; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPathMissing(error: unknown): boolean { + const code = getErrorCode(error); + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function getErrorCode(error: unknown): unknown { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined; + return (error as { code: unknown }).code; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 49d8d8105..2e56f2aba 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -2,6 +2,7 @@ import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; +import { normalizeAdditionalDirs } from '../config'; import { listDirectory } from '../tools/support/list-directory'; import type { SystemPromptContext } from './types'; @@ -11,23 +12,38 @@ const AGENTS_MD_TRUNCATION_MARKER = const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick; +export type PreparedSystemPromptContext = Pick< + SystemPromptContext, + 'cwdListing' | 'agentsMd' | 'additionalDirsInfo' +>; + +export interface PrepareSystemPromptContextOptions { + readonly additionalDirs?: readonly string[]; +} export async function prepareSystemPromptContext( kaos: Kaos, brandHome?: string, + options?: PrepareSystemPromptContextOptions, ): Promise { - const [cwdListing, agentsMd] = await Promise.all([ + const additionalDirs = normalizeAdditionalDirs(options?.additionalDirs ?? []); + const [cwdListing, agentsMd, additionalDirsInfo] = await Promise.all([ listDirectory(kaos, undefined, { collapseHiddenDirs: true }), loadAgentsMd(kaos, brandHome), + loadAdditionalDirsInfo(kaos, additionalDirs), ]); - return { cwdListing, agentsMd }; + return { cwdListing, agentsMd, additionalDirsInfo }; } export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { - const workDir = kaos.getcwd(); - const projectRoot = await findProjectRoot(kaos, workDir); - const dirs = dirsRootToLeaf(kaos, workDir, projectRoot); + return loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]); +} + +async function loadAgentsMdForRoots( + kaos: Kaos, + brandHome: string | undefined, + workDirs: readonly string[], +): Promise { const discovered: AgentFile[] = []; const seen = new Set(); @@ -57,16 +73,37 @@ export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { + const sections = await Promise.all( + additionalDirs.map(async (dir) => { + const listing = await listDirectory(kaos.withCwd(dir)); + return `### ${dir}\n${listing}`; + }), + ); + + return sections.join('\n\n'); +} + async function findProjectRoot(kaos: Kaos, workDir: string): Promise { const initial = kaos.normpath(workDir); let current = initial; @@ -163,13 +200,34 @@ function renderAgentFiles(files: readonly AgentFile[]): string { } function truncateUtf8(text: string, maxBytes: number): string { - let result = text; - while (byteLength(result) > maxBytes) { + if (maxBytes <= 0) return ''; + if (byteLength(text) <= maxBytes) return text; + + let low = 0; + let high = text.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + const candidate = text.slice(0, mid); + if (byteLength(candidate) <= maxBytes) { + low = mid; + } else { + high = mid - 1; + } + } + + let result = text.slice(0, low); + while (endsWithUnpairedHighSurrogate(result)) { result = result.slice(0, -1); } return result; } +function endsWithUnpairedHighSurrogate(text: string): boolean { + if (text.length === 0) return false; + const codePoint = text.codePointAt(text.length - 1); + return codePoint !== undefined && codePoint >= 0xd800 && codePoint <= 0xdbff; +} + function byteLength(text: string): number { return Buffer.byteLength(text, 'utf8'); } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index c3ba9f6a5..3d61257f0 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -48,6 +48,7 @@ export interface CreateSessionPayload { readonly permission?: PermissionMode | undefined; readonly metadata?: JsonObject | undefined; readonly mcpServers?: Readonly>; + readonly additionalDirs?: readonly string[]; } export interface CloseSessionPayload { @@ -57,6 +58,7 @@ export interface CloseSessionPayload { export interface ResumeSessionPayload { readonly sessionId: string; readonly mcpServers?: Readonly>; + readonly additionalDirs?: readonly string[]; } export interface ReloadSessionPayload { @@ -140,6 +142,7 @@ export interface SessionSummary { readonly updatedAt: number; readonly archived?: boolean | undefined; readonly metadata?: JsonObject | undefined; + readonly additionalDirs?: readonly string[]; } export interface PromptPayload { @@ -148,6 +151,9 @@ export interface PromptPayload { export interface SteerPayload { readonly input: readonly ContentPart[]; } +export interface AppendUserMessagePayload { + readonly input: readonly ContentPart[]; +} export interface CancelPayload { readonly turnId?: number; } @@ -263,6 +269,18 @@ export interface GetPluginInfoPayload { export type ReloadPluginsResult = ReloadSummary; export type { PluginSummary, PluginInfo }; +export interface AddAdditionalDirPayload { + readonly path: string; + readonly persist: boolean; +} + +export interface AddAdditionalDirResult { + readonly additionalDirs: readonly string[]; + readonly projectRoot: string; + readonly configPath: string; + readonly persisted: boolean; +} + export interface RenameSessionPayload { readonly title: string; } @@ -308,6 +326,7 @@ export interface RemoveKimiProviderPayload { export interface AgentAPI { prompt: (payload: PromptPayload) => void; steer: (payload: SteerPayload) => void; + appendUserMessage: (payload: AppendUserMessagePayload) => void; cancel: (payload: CancelPayload) => void; undoHistory: (payload: UndoHistoryPayload) => void; setThinking: (payload: SetThinkingPayload) => void; @@ -355,6 +374,7 @@ export interface SessionAPI extends AgentAPIWithId { getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics; reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; generateAgentsMd: (payload: EmptyPayload) => void; + addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; } type SessionAPIWithId = WithSessionId; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 204715da6..a1ff49eb7 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -16,6 +16,9 @@ import { loadRuntimeConfigSafe, mergeConfigPatch, readConfigFileForUpdate, + normalizeAdditionalDirs, + readWorkspaceAdditionalDirs, + resolveWorkspaceAdditionalDirs, resolveConfigPath, resolveKimiHome, writeConfigFile, @@ -42,6 +45,9 @@ import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from import type { CoreRPCClient } from './client'; import type { ActivateSkillPayload, + AddAdditionalDirPayload, + AddAdditionalDirResult, + AppendUserMessagePayload, BeginCompactionPayload, CancelPayload, CancelPlanPayload, @@ -212,6 +218,18 @@ export class KimiCore implements PromisableMethods { homeDir: this.homeDir, }); const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, options.mcpServers); + const parentKaos = overrides.kaos ?? (await this.getKaos()); + const persistenceKaos = overrides.persistenceKaos ?? parentKaos; + const localWorkspaceDirs = await readWorkspaceAdditionalDirs(parentKaos, workDir); + const callerAdditionalDirs = await resolveWorkspaceAdditionalDirs( + parentKaos, + workDir, + options.additionalDirs ?? [], + ); + const additionalDirs = normalizeAdditionalDirs([ + ...localWorkspaceDirs.additionalDirs, + ...callerAdditionalDirs, + ]); const summary = await this.sessionStore.create({ id, workDir, @@ -228,8 +246,6 @@ export class KimiCore implements PromisableMethods { // Session ctor attaches its own log sink. If anything in the setup-after- // ctor block throws, `session.close()` releases the sink (and mcp). const runtime = await this.resolveRuntime(config); - const parentKaos = overrides.kaos ?? (await this.getKaos()); - const persistenceKaos = overrides.persistenceKaos ?? parentKaos; const session = new Session({ kaos: parentKaos.withCwd(workDir), persistenceKaos, @@ -249,6 +265,7 @@ export class KimiCore implements PromisableMethods { telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), pluginSessionStarts, appVersion: this.appVersion, + additionalDirs, }); try { session.metadata = { @@ -283,7 +300,7 @@ export class KimiCore implements PromisableMethods { throw error; } this.sessions.set(id, session); - return result; + return withAdditionalDirs(result, session); } getCoreInfo(): CoreInfo { @@ -311,12 +328,24 @@ export class KimiCore implements PromisableMethods { overrides: { kaos?: Kaos; persistenceKaos?: Kaos }, ): Promise { const summary = await this.sessionStore.get(input.sessionId); + const parentKaosForRead = overrides.kaos ?? (await this.getKaos()); + const localWorkspaceDirs = await readWorkspaceAdditionalDirs(parentKaosForRead, summary.workDir); + const callerAdditionalDirs = await resolveWorkspaceAdditionalDirs( + parentKaosForRead, + summary.workDir, + input.additionalDirs ?? [], + ); + const additionalDirs = normalizeAdditionalDirs([ + ...localWorkspaceDirs.additionalDirs, + ...callerAdditionalDirs, + ]); const active = this.sessions.get(summary.id); if (active !== undefined) { if (overrides.kaos !== undefined) { active.setToolKaos(overrides.kaos.withCwd(summary.workDir)); } - return resumeSessionResult(summary, active); + await active.setAdditionalDirs(additionalDirs); + return withAdditionalDirs(await resumeSessionResult(summary, active), active); } const config = this.reloadProviderManager(); @@ -329,7 +358,7 @@ export class KimiCore implements PromisableMethods { const pluginSessionStarts = this.plugins.enabledSessionStarts(); const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); const runtime = await this.resolveRuntime(config); - const parentKaos = overrides.kaos ?? (await this.getKaos()); + const parentKaos = parentKaosForRead; const persistenceKaos = overrides.persistenceKaos ?? parentKaos; const session = new Session({ kaos: parentKaos.withCwd(summary.workDir), @@ -351,6 +380,7 @@ export class KimiCore implements PromisableMethods { initializeMainAgent: false, pluginSessionStarts, appVersion: this.appVersion, + additionalDirs, }); let warning: string | undefined; try { @@ -515,6 +545,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).steer(payload); } + appendUserMessage({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).appendUserMessage(payload); + } + cancel({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).cancel(payload); } @@ -674,6 +708,13 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).generateAgentsMd(payload); } + addAdditionalDir({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.requireSession(sessionId).addAdditionalDir(payload.path, payload.persist); + } + startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { return this.sessionApi(sessionId).startBtw(payload); } @@ -867,14 +908,18 @@ export class KimiCore implements PromisableMethods { return env; } - private sessionApi(sessionId: string): SessionAPIImpl { + private requireSession(sessionId: string): Session { const session = this.sessions.get(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { details: { sessionId }, }); } - return new SessionAPIImpl(session); + return session; + } + + private sessionApi(sessionId: string): SessionAPIImpl { + return new SessionAPIImpl(this.requireSession(sessionId)); } private reloadProviderManager(): KimiConfig { @@ -1017,6 +1062,16 @@ function createSessionId(): string { return `session_${randomUUID()}`; } +function withAdditionalDirs( + result: T, + session: Session, +): T & { readonly additionalDirs: readonly string[] } { + return { + ...result, + additionalDirs: session.getAdditionalDirs(), + }; +} + function telemetryErrorReason(error: unknown): string { if (error instanceof KimiError) return error.code; if (error instanceof Error && error.name.length > 0) return error.name; @@ -1053,12 +1108,15 @@ async function resumeSessionResult( background: agent.background.list(false), }; } - return { - ...summary, - sessionMetadata: api.getSessionMetadata({}), - agents, - warning, - }; + return withAdditionalDirs( + { + ...summary, + sessionMetadata: api.getSessionMetadata({}), + agents, + warning, + }, + session, + ); } async function warnIfLogFlushFails( diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..945990b79 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -11,7 +11,16 @@ import { proxyWithExtraPayload } from '#/rpc/types'; import { Agent, type AgentOptions, type AgentType } from '../agent'; import { HookEngine, type HookDef } from './hooks'; import type { PermissionManagerOptions, PermissionRule } from '../agent/permission'; -import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config'; +import { + appendWorkspaceAdditionalDir, + normalizeAdditionalDirs, + parseBooleanEnv, + readWorkspaceAdditionalDirs, + resolveWorkspaceAdditionalDirs, + resolveConfigValue, + type BackgroundConfig, + type WorkspaceAdditionalDirsLoadResult, +} from '../config'; import { makeErrorPayload } from '../errors'; import { McpConnectionManager, @@ -62,6 +71,7 @@ export interface SessionOptions { readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly appVersion?: string; readonly experimentalFlags?: ExperimentalFlagResolver; + readonly additionalDirs?: readonly string[]; } export interface SessionSkillConfig { @@ -147,6 +157,7 @@ export class Session { readonly experimentalFlags: ExperimentalFlagResolver; private toolKaos: Kaos; private persistenceKaos: Kaos; + private additionalDirs: readonly string[]; private agentIdCounter = 0; private readonly skillsReady: Promise; metadata: SessionMeta = { @@ -182,6 +193,7 @@ export class Session { this.telemetry = options.telemetry ?? noopTelemetryClient; this.toolKaos = options.kaos; this.persistenceKaos = options.persistenceKaos ?? options.kaos; + this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []); this.skills = new SessionSkillRegistry({ sessionId: options.id, }); @@ -213,6 +225,42 @@ export class Session { this.refreshAgentBuiltinTools(); } + getAdditionalDirs(): readonly string[] { + return this.additionalDirs; + } + + async setAdditionalDirs(additionalDirs: readonly string[]): Promise { + this.additionalDirs = normalizeAdditionalDirs(additionalDirs); + for (const agent of this.readyAgents()) { + agent.setAdditionalDirs(this.additionalDirs); + } + } + + async addAdditionalDir( + path: string, + persist = true, + ): Promise { + const cwd = this.toolKaos.getcwd(); + const systemKaos = this.systemContextKaos(cwd); + if (persist) { + const result = await appendWorkspaceAdditionalDir(systemKaos, cwd, path, this.additionalDirs); + const additionalDirs = normalizeAdditionalDirs([...this.additionalDirs, ...result.additionalDirs]); + await this.setAdditionalDirs(additionalDirs); + return { ...result, additionalDirs, persisted: true }; + } + + const workspace = await readWorkspaceAdditionalDirs(systemKaos, cwd); + const additionalDirs = await resolveWorkspaceAdditionalDirs(systemKaos, workspace.projectRoot, [path]); + const nextAdditionalDirs = normalizeAdditionalDirs([...this.additionalDirs, ...additionalDirs]); + await this.setAdditionalDirs(nextAdditionalDirs); + return { + projectRoot: workspace.projectRoot, + configPath: workspace.configPath, + additionalDirs: nextAdditionalDirs, + persisted: false, + }; + } + /** * Kaos used by session-internal bootstrap (AGENTS.md context, cwd listing) * and metadata persistence. Always backed by the persistence sink (typically @@ -407,6 +455,7 @@ export class Session { const context = await prepareSystemPromptContext( this.systemContextKaos(agent.kaos.getcwd()), this.options.kimiHomeDir, + { additionalDirs: this.additionalDirs }, ); agent.useProfile(profile, context); } @@ -581,6 +630,7 @@ export class Session { log: this.log.createChild({ agentId: id }), pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined, experimentalFlags: this.experimentalFlags, + additionalDirs: parentAgent?.getAdditionalDirs() ?? this.additionalDirs, }); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index fe81014dc..c556fff3d 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -1,7 +1,10 @@ import { ErrorCodes, KimiError } from '#/errors'; import type { ActivateSkillPayload, + AddAdditionalDirPayload, + AddAdditionalDirResult, AgentAPI, + AppendUserMessagePayload, BeginCompactionPayload, CancelPayload, CancelPlanPayload, @@ -90,6 +93,9 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.generateAgentsMd(); } + addAdditionalDir(payload: AddAdditionalDirPayload): Promise { + return this.session.addAdditionalDir(payload.path, payload.persist); + } async prompt({ agentId, ...payload }: AgentScopedPayload) { if (agentId === 'main') { @@ -102,6 +108,10 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).steer(payload); } + async appendUserMessage({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).appendUserMessage(payload); + } + async cancel({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).cancel(payload); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..acc124692 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -365,6 +365,7 @@ export class SessionSubagentHost { const context = await prepareSystemPromptContext( this.session.systemContextKaos(child.kaos.getcwd()), this.session.options.kimiHomeDir, + { additionalDirs: child.getAdditionalDirs() }, ); child.useProfile(profile, context); child.tools.inheritUserTools(parent.tools); diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index 5fc9edf88..acd80625a 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -82,6 +82,31 @@ describe('Agent config', () => { await ctx.expectResumeMatches(); }); + it('useProfile passes additionalDirsInfo to profile system prompts', async () => { + const ctx = testAgent(); + ctx.configure(); + const profile: ResolvedAgentProfile = { + name: 'context-profile', + systemPrompt: (context) => + `Prompt with additional dirs: ${context.additionalDirsInfo ?? 'none'}`, + tools: ['Bash'], + }; + + ctx.agent.useProfile(profile, { + cwdListing: 'cwd listing', + agentsMd: 'agents md', + additionalDirsInfo: '### /extra\nextra-file.txt', + }); + + expect(ctx.agent.config.systemPrompt).toBe( + 'Prompt with additional dirs: ### /extra\nextra-file.txt', + ); + + ctx.agent.useProfile(profile); + + expect(ctx.agent.config.systemPrompt).toBe('Prompt with additional dirs: none'); + }); + it('config.update with cwd initializes builtin tools', async () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 243ca7572..fb88c4eab 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -2681,6 +2681,39 @@ describe('Default git CWD Write/Edit permission', () => { expect(stat).not.toHaveBeenCalled(); }); + it('still requests approval for Bash when the cwd is an additionalDir', async () => { + const { kaos, stat } = gitKaos({ + markerPath: '/extra/.git', + statModes: { '/extra': DIR_MODE }, + }); + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { cwd: '/extra', kaos, additionalDirs: ['/extra'] }, + ); + + await expect( + manager.beforeToolCall( + hookContext({ + id: 'call_bash_additional_dir_cwd', + args: { command: 'printf from-additional-dir', timeout: 60 }, + }), + ), + ).resolves.toBeUndefined(); + + expect(requestApproval).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'Bash', + action: 'run command', + }), + expect.any(Object), + ); + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ policy_name: 'git-cwd-write-approve' }), + ); + expect(stat).not.toHaveBeenCalled(); + }); + it('bypasses approval for Write to a relative path inside a git cwd', async () => { const { kaos } = gitKaos(); const { manager, requestApproval, telemetryTrack } = makePermissionManager( @@ -2729,6 +2762,38 @@ describe('Default git CWD Write/Edit permission', () => { ); }); + it.each([ + ['Write', { path: '/extra/src/a.ts', content: 'x' }], + ['Edit', { path: '/extra/src/a.ts', old_string: 'A', new_string: 'B' }], + ] as const)('approves %s on an additionalDir path in manual mode', async (toolName, args) => { + const { kaos } = gitKaos(); + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { kaos, additionalDirs: ['/extra'] }, + ); + + await expect( + manager.beforeToolCall( + hookContext({ + id: `call_${toolName.toLowerCase()}_additional_dir`, + toolName, + args, + }), + ), + ).resolves.toBeUndefined(); + + expect(requestApproval).not.toHaveBeenCalled(); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'git-cwd-write-approve', + tool_name: toolName, + permission_mode: 'manual', + decision: 'approve', + }), + ); + }); + it('still requests approval when cwd is not inside a git work tree', async () => { const { manager, requestApproval, telemetryTrack } = makePermissionManager( async () => ({ decision: 'approved' }), @@ -2803,6 +2868,28 @@ describe('Default git CWD Write/Edit permission', () => { expect(requestApproval).toHaveBeenCalledTimes(1); }); + it('still requests approval for a shared-prefix path outside additionalDirs', async () => { + const { kaos } = gitKaos(); + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { kaos, additionalDirs: ['/extra'] }, + ); + + await expect( + manager.beforeToolCall(writeHook({ path: '/extra-evil/outside.ts', content: 'x' })), + ).resolves.toBeUndefined(); + + expect(requestApproval).toHaveBeenCalledTimes(1); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ policy_name: 'fallback-ask' }), + ); + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ policy_name: 'git-cwd-write-approve' }), + ); + }); + it('still requests approval for a path inside the git root but outside the cwd', async () => { const { kaos } = gitKaos({ markerPath: '/a/.git' }); const { manager, requestApproval, telemetryTrack } = makePermissionManager( @@ -2841,6 +2928,34 @@ describe('Default git CWD Write/Edit permission', () => { }, ); + it('still requests approval for a git control file inside an additionalDir', async () => { + const { kaos } = gitKaos(); + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { kaos, additionalDirs: ['/extra'] }, + ); + + await expect( + manager.beforeToolCall(writeHook({ path: '/extra/.git/config', content: 'x' })), + ).resolves.toBeUndefined(); + + expect(requestApproval).toHaveBeenCalledTimes(1); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'git-control-path-access-ask', + tool_name: 'Write', + permission_mode: 'manual', + decision: 'ask', + git_control_path: true, + }), + ); + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ policy_name: 'git-cwd-write-approve' }), + ); + }); + it('still requests approval for case-variant git control files', async () => { const { kaos } = gitKaos(); const { manager, requestApproval, telemetryTrack } = makePermissionManager( @@ -3103,6 +3218,34 @@ describe('Default git CWD Write/Edit permission', () => { expect(requestApproval).toHaveBeenCalledTimes(1); }); + it('still requests approval for a sensitive file inside an additionalDir', async () => { + const { kaos } = gitKaos(); + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { kaos, additionalDirs: ['/extra'] }, + ); + + await expect( + manager.beforeToolCall(writeHook({ path: '/extra/.env', content: 'SECRET=1' })), + ).resolves.toBeUndefined(); + + expect(requestApproval).toHaveBeenCalledTimes(1); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'sensitive-file-access-ask', + tool_name: 'Write', + permission_mode: 'manual', + decision: 'ask', + sensitive_path: true, + }), + ); + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ policy_name: 'git-cwd-write-approve' }), + ); + }); + it.each(['.env.local', '.aws/credentials'])( 'still requests approval for sensitive file %s', async (path) => { @@ -3680,6 +3823,7 @@ function makePermissionManager( readonly planFilePath?: string | null | undefined; readonly kaos?: Kaos; readonly cwd?: string; + readonly additionalDirs?: readonly string[]; readonly agentType?: Agent['type']; readonly hooks?: Agent['hooks']; readonly approvalRpc?: boolean; @@ -3699,6 +3843,7 @@ function makePermissionManager( type: options.agentType ?? 'main', config: { cwd: options.cwd ?? '/workspace' }, kaos: options.kaos ?? createFakeKaos(), + getAdditionalDirs: () => options.additionalDirs ?? [], emitStatusUpdated: vi.fn(), records: { logRecord: record }, replayBuilder: { push: vi.fn() }, diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts new file mode 100644 index 000000000..529a467ca --- /dev/null +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -0,0 +1,179 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { testKaos } from '../fixtures/test-kaos'; +import { ErrorCodes, KimiError } from '../../src/errors'; +import { + appendWorkspaceAdditionalDir, + loadWorkspaceLocalConfig, + normalizeAdditionalDirs, + readWorkspaceAdditionalDirs, +} from '../../src/config/workspace-local'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeProject(): Promise { + const root = await mkdtemp(join(tmpdir(), 'kimi-workspace-local-')); + tempDirs.push(root); + await mkdir(join(root, '.git'), { recursive: true }); + await mkdir(join(root, 'packages', 'app'), { recursive: true }); + return root; +} + +async function expectConfigInvalid( + promise: Promise, + message: string, +): Promise { + await expect(promise).rejects.toBeInstanceOf(KimiError); + await expect(promise).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + message: expect.stringContaining(message), + }); +} + +describe('workspace local config', () => { + it('returns empty workspace config when local.toml is missing', async () => { + const root = await makeProject(); + + await expect(loadWorkspaceLocalConfig(testKaos, join(root, 'packages', 'app'))).resolves.toEqual({ + projectRoot: root, + configPath: join(root, '.kimi-code', 'local.toml'), + additionalDirs: [], + }); + }); + + it('loads additional_dir array from the project root when started nested', async () => { + const root = await makeProject(); + const sharedDir = join(root, 'shared'); + const otherDir = join(root, 'other'); + await mkdir(sharedDir, { recursive: true }); + await mkdir(otherDir, { recursive: true }); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + join(root, '.kimi-code', 'local.toml'), + '[workspace]\nadditional_dir = ["shared", "other"]\n', + 'utf-8', + ); + + await expect(readWorkspaceAdditionalDirs(testKaos, join(root, 'packages', 'app'))).resolves.toEqual({ + projectRoot: root, + configPath: join(root, '.kimi-code', 'local.toml'), + additionalDirs: [sharedDir, otherDir], + }); + }); + + it('rejects string additional_dir values', async () => { + const root = await makeProject(); + await mkdir(join(root, 'shared'), { recursive: true }); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + join(root, '.kimi-code', 'local.toml'), + '[workspace]\nadditional_dir = "shared"\n', + 'utf-8', + ); + + await expectConfigInvalid( + loadWorkspaceLocalConfig(testKaos, join(root, 'packages', 'app')), + 'workspace.additional_dir must be an array of strings', + ); + }); + + it('rejects configured additional_dir that does not exist', async () => { + const root = await makeProject(); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + join(root, '.kimi-code', 'local.toml'), + '[workspace]\nadditional_dir = ["missing"]\n', + 'utf-8', + ); + + await expectConfigInvalid( + readWorkspaceAdditionalDirs(testKaos, join(root, 'packages', 'app')), + 'workspace.additional_dir must exist and be a directory', + ); + }); + + it('appends multiple directories and deduplicates normalized paths', async () => { + const root = await makeProject(); + const sharedDir = join(root, 'shared'); + const otherDir = join(root, 'other'); + await mkdir(sharedDir, { recursive: true }); + await mkdir(otherDir, { recursive: true }); + + const appended = await appendWorkspaceAdditionalDir(testKaos, root, 'shared', []); + const configPath = join(root, '.kimi-code', 'local.toml'); + const before = await readFile(configPath, 'utf-8'); + + const duplicate = await appendWorkspaceAdditionalDir(testKaos, root, './shared', []); + const afterDuplicate = await readFile(configPath, 'utf-8'); + const second = await appendWorkspaceAdditionalDir(testKaos, root, 'other', duplicate.additionalDirs); + + expect(duplicate).toEqual(appended); + expect(afterDuplicate).toBe(before); + expect(second.additionalDirs).toEqual([sharedDir, otherDir]); + }); + + it('uses the actual local.toml state even when current dirs are empty', async () => { + const root = await makeProject(); + const sharedDir = join(root, 'shared'); + const otherDir = join(root, 'other'); + await mkdir(sharedDir, { recursive: true }); + await mkdir(otherDir, { recursive: true }); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + const configPath = join(root, '.kimi-code', 'local.toml'); + await writeFile(configPath, '[workspace]\nadditional_dir = ["shared"]\n', 'utf-8'); + + const result = await appendWorkspaceAdditionalDir(testKaos, root, 'other', []); + + expect(result.additionalDirs).toEqual([sharedDir, otherDir]); + }); + + it('does not rewrite local.toml when appending an existing directory', async () => { + const root = await makeProject(); + const sharedDir = join(root, 'shared'); + await mkdir(sharedDir, { recursive: true }); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + const configPath = join(root, '.kimi-code', 'local.toml'); + const before = '[workspace]\nadditional_dir = ["shared"]\n'; + await writeFile(configPath, before, 'utf-8'); + + const result = await appendWorkspaceAdditionalDir(testKaos, root, './shared', []); + + expect(result.additionalDirs).toEqual([sharedDir]); + await expect(readFile(configPath, 'utf-8')).resolves.toBe(before); + }); + + it('rejects missing paths when appending additional_dir', async () => { + const root = await makeProject(); + + await expectConfigInvalid( + appendWorkspaceAdditionalDir(testKaos, root, 'missing', []), + 'workspace.additional_dir must exist and be a directory', + ); + }); + + it('rejects non-directory paths when appending additional_dir', async () => { + const root = await makeProject(); + await writeFile(join(root, 'shared'), 'not a directory', 'utf-8'); + + await expectConfigInvalid( + appendWorkspaceAdditionalDir(testKaos, root, 'shared', []), + 'workspace.additional_dir must exist and be a directory', + ); + }); + + it('deduplicates normalized additional dirs while preserving order', () => { + expect( + normalizeAdditionalDirs(['shared', './shared', 'nested//dir', 'nested/dir/../final']), + ).toEqual(['shared', 'nested/dir', 'nested/final']); + }); +}); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index f660e2e99..d7ca6fb06 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -291,6 +291,449 @@ max_context_size = 100000 expect(mainAgent?.config.modelAlias).toBe('default-mock'); }); + it('loads project local additional dirs into the session and main agent', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["extra"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs', + workDir, + model: 'default-mock', + }); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(created.additionalDirs).toEqual([extraDir]); + expect(session?.getAdditionalDirs()).toEqual([extraDir]); + expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]); + expect(mainAgent?.config.systemPrompt).toContain('## Additional Directories'); + expect(mainAgent?.config.systemPrompt).toContain(extraDir); + }); + + it('returns additionalDirs when resuming an active session', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["extra"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_active_resume', + workDir, + model: 'default-mock', + }); + const resumed = await rpc.resumeSession({ sessionId: created.id }); + + expect(resumed.additionalDirs).toEqual([extraDir]); + expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([extraDir]); + }); + + it('returns additionalDirs when resuming a closed session', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["extra"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_closed_resume', + workDir, + model: 'default-mock', + }); + await rpc.closeSession({ sessionId: created.id }); + + const resumed = await rpc.resumeSession({ sessionId: created.id }); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(resumed.additionalDirs).toEqual([extraDir]); + expect(session?.getAdditionalDirs()).toEqual([extraDir]); + expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]); + }); + + it('merges caller additionalDirs when resuming a closed session', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const localDir = join(workDir, 'local'); + const callerDir = join(workDir, 'caller'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(localDir, { recursive: true }); + await mkdir(callerDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["local"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_resume_caller', + workDir, + model: 'default-mock', + }); + await rpc.closeSession({ sessionId: created.id }); + + const resumed = await rpc.resumeSession({ + sessionId: created.id, + additionalDirs: ['caller'], + }); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(resumed.additionalDirs).toEqual([localDir, callerDir]); + expect(session?.getAdditionalDirs()).toEqual([localDir, callerDir]); + expect(mainAgent?.getAdditionalDirs()).toEqual([localDir, callerDir]); + }); + + it('deduplicates project local and caller relative additionalDirs after resolving them', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const sharedDir = join(workDir, 'shared'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(sharedDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["shared"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_dedupe', + workDir, + model: 'default-mock', + additionalDirs: ['shared'], + }); + + expect(created.additionalDirs).toEqual([sharedDir]); + expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([sharedDir]); + }); + + it('supports multiple project local and caller additionalDirs', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const localDir = join(workDir, 'shared'); + const callerDir = join(workDir, 'other'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(localDir, { recursive: true }); + await mkdir(callerDir, { recursive: true }); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeFile( + join(workDir, '.kimi-code', 'local.toml'), + `[workspace]\nadditional_dir = ["shared"]\n`, + ); + + const [coreRpc, sdkRpc] = createRPC(); + void new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_multiple', + workDir, + model: 'default-mock', + additionalDirs: ['other'], + }); + + expect(created.additionalDirs).toEqual([localDir, callerDir]); + }); + + it('resolves caller relative additionalDirs against workDir rather than projectRoot', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const projectRoot = join(tmp, 'repo'); + const workDir = join(projectRoot, 'apps', 'foo'); + const sharedDir = join(workDir, 'shared'); + await mkdir(homeDir, { recursive: true }); + await mkdir(join(projectRoot, '.git'), { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(sharedDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_additional_dirs_workdir_relative', + workDir, + model: 'default-mock', + additionalDirs: ['shared'], + }); + + expect(created.additionalDirs).toEqual([sharedDir]); + expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([sharedDir]); + }); + + it('does not record an add-dir system reminder through the session RPC', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_add_additional_dir_record', + workDir, + model: 'default-mock', + }); + + await rpc.addAdditionalDir({ + sessionId: created.id, + path: 'extra', + persist: true, + }); + + const records = await readMainWire(created.sessionDir); + expect(records).not.toContainEqual( + expect.objectContaining({ + type: 'context.append_message', + message: expect.objectContaining({ + origin: { kind: 'injection', variant: 'add-dir' }, + }), + }), + ); + expect(core.sessions.get(created.id)?.getReadyAgent('main')?.getAdditionalDirs()).toEqual([ + extraDir, + ]); + }); + + it('appends a user message to the main agent context', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + void new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_append_user_message', + workDir, + model: 'default-mock', + }); + + await rpc.appendUserMessage({ + sessionId: created.id, + agentId: 'main', + input: [{ type: 'text', text: 'Added workspace directory:\n extra' }], + }); + + const records = await readMainWire(created.sessionDir); + expect(records).toContainEqual( + expect.objectContaining({ + type: 'context.append_message', + message: expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'Added workspace directory:\n extra' }], + }), + }), + ); + }); + + it('adds an additional dir through the session RPC', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_add_additional_dir', + workDir, + model: 'default-mock', + }); + + const result = await rpc.addAdditionalDir({ + sessionId: created.id, + path: 'extra', + persist: true, + }); + const localToml = await readFile(join(workDir, '.kimi-code', 'local.toml'), 'utf-8'); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(result).toMatchObject({ + additionalDirs: [extraDir], + projectRoot: workDir, + configPath: join(workDir, '.kimi-code', 'local.toml'), + persisted: true, + }); + expect(localToml).toContain('additional_dir = ['); + expect(session?.getAdditionalDirs()).toEqual([extraDir]); + expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]); + }); + + it('adds a session-only additional dir without writing local.toml or reminder', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const extraDir = join(workDir, 'extra'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await mkdir(extraDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_add_session_only_dir', + workDir, + model: 'default-mock', + }); + + const result = await rpc.addAdditionalDir({ + sessionId: created.id, + path: 'extra', + persist: false, + }); + const records = await readMainWire(created.sessionDir); + + expect(result).toMatchObject({ + additionalDirs: [extraDir], + projectRoot: workDir, + configPath: join(workDir, '.kimi-code', 'local.toml'), + persisted: false, + }); + expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([extraDir]); + expect(records).not.toContainEqual( + expect.objectContaining({ + type: 'context.append_message', + message: expect.objectContaining({ + origin: { kind: 'injection', variant: 'add-dir' }, + }), + }), + ); + await expect(readFile(join(workDir, '.kimi-code', 'local.toml'), 'utf-8')).rejects.toThrow(); + }); + it('rejects createSession when shell runtime initialization fails', async () => { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); @@ -436,6 +879,15 @@ base_url = "https://search.example.test/v1" }); }); +async function readMainWire(sessionDir: string): Promise[]> { + const wire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + return wire + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + function baseModelConfig(): string { return `default_model = "default-mock" diff --git a/packages/agent-core/test/profile/context.test.ts b/packages/agent-core/test/profile/context.test.ts index 150f69c04..f7a6bfa5c 100644 --- a/packages/agent-core/test/profile/context.test.ts +++ b/packages/agent-core/test/profile/context.test.ts @@ -4,15 +4,17 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadAgentsMd } from '../../src/profile/context'; +import { loadAgentsMd, prepareSystemPromptContext } from '../../src/profile/context'; import { testKaos } from '../fixtures/test-kaos'; let homeDir: string; let workDir: string; +let extraDirs: string[]; beforeEach(async () => { homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-')); workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-')); + extraDirs = []; vi.spyOn(testKaos, 'gethome').mockReturnValue(homeDir); vi.spyOn(testKaos, 'getcwd').mockReturnValue(workDir); }); @@ -21,6 +23,7 @@ afterEach(async () => { vi.restoreAllMocks(); await rm(homeDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true }); + await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true }))); }); describe('loadAgentsMd user-level discovery', () => { @@ -124,3 +127,54 @@ describe('loadAgentsMd truncation marker', () => { expect(result).not.toContain(largeContent); }); }); + +describe('prepareSystemPromptContext additional directories', () => { + it('includes additional directory listings without loading their AGENTS.md', async () => { + const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-')); + extraDirs.push(brandHome); + const extraDir = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-')); + extraDirs.push(extraDir); + + await writeFile(join(workDir, 'AGENTS.md'), 'repo project instructions', 'utf-8'); + await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8'); + await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8'); + + const result = await prepareSystemPromptContext(testKaos, brandHome, { + additionalDirs: [extraDir], + }); + + const agentsMd = result.agentsMd ?? ''; + + expect(result.cwdListing).toBeTypeOf('string'); + expect(result.additionalDirsInfo).toContain(`### ${extraDir}`); + expect(result.additionalDirsInfo).toContain('extra-file.txt'); + expect(agentsMd).toContain('repo project instructions'); + expect(agentsMd).not.toContain('extra project instructions'); + expect(agentsMd.split('