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:
haozhe.yang 2026-07-29 19:03:45 +08:00
parent 5253f81bea
commit 4c23ee7ed2
33 changed files with 1491 additions and 44 deletions

View 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));
}
}

View file

@ -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;

View file

@ -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'],

View file

@ -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() !== '/') {

View file

@ -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;

View 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();
});
});

View file

@ -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',

View file

@ -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',

View file

@ -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" },
];

View file

@ -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,

View file

@ -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);

View file

@ -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 */ {

View file

@ -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],

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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 {

View file

@ -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]];
}

View file

@ -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');

View file

@ -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',
);

View file

@ -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[];
}

View file

@ -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;

View file

@ -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,

View file

@ -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>,

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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');

View file

@ -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();

View file

@ -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

View file

@ -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 });

View file

@ -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 { }

View file

@ -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-');

View file

@ -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);
}
});
});
// ---------------------------------------------------------------------------