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