feat(agent-core-v2): add workspace add-dir command

This commit is contained in:
7Sageer 2026-07-07 12:35:49 +08:00
parent b37345e5d5
commit 339022b375
17 changed files with 904 additions and 2 deletions

View file

@ -54,6 +54,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>agentLifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>interaction</b>\n<size:9><i>Session</i></size>\n IInteractionService" as interaction #D5F5E3
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>workspaceCommand</b>\n<size:9><i>Session</i></size>\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
rectangle "<b>sessionLog</b>\n<size:9><i>Session binding</i></size>\n ILogService" as sessionLog #D5F5E3
rectangle "<b>sessionSkillCatalog</b>\n<size:9><i>Session</i></size>\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3
rectangle "<b>sessionFs</b>\n<size:9><i>Session</i></size>\n ISessionFsService" as sessionFs #D5F5E3
@ -141,6 +142,10 @@ modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
workspaceContext --> session_context #34495E
workspaceCommand --> bootstrap #34495E
workspaceCommand --> workspaceContext #34495E
workspaceCommand --> agent_lifecycle #34495E
workspaceCommand --> hostFs #34495E
sessionLog --> session_context #34495E
sessionSkillCatalog --> skillCatalog #34495E
sessionSkillCatalog --> plugin #34495E

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

View file

@ -177,6 +177,13 @@ const DOMAIN_LAYER = new Map([
['sessionActivity', 6],
['session', 6],
['terminal', 6],
// `workspaceCommand` orchestrates session-level workspace mutations
// (`addAdditionalDir`): it reaches through `agentLifecycle` (L6) to the
// `main` agent's `contextMemory` (L4) to mirror the action's stdout, and
// reads/writes the workspace-local config through `os/interface` (L1). Its
// highest real dependency is `agentLifecycle`, so it sits in L6 beside the
// other coordination domains.
['workspaceCommand', 6],
// L7 — boundary
['approval', 7],
['question', 7],

View file

@ -71,6 +71,7 @@ export * from '#/agent/questionTools';
export * from '#/app/gateway';
export * from '#/session/workspaceContext';
export * from '#/session/workspaceCommand';
export * from '#/app/workspaceRegistry';
export * from '#/session/process';
export * from '#/session/sessionFs';

View file

@ -0,0 +1,11 @@
/**
* `workspaceCommand` domain barrel re-exports the workspace-command contract
* (`workspaceCommand`), its scoped service (`workspaceCommandService`), and the
* workspace-local-config helpers (`workspaceLocalConfig`). Importing this
* barrel registers the `ISessionWorkspaceCommandService` binding into the scope
* registry.
*/
export * from './workspaceCommand';
export * from './workspaceCommandService';
export * from './workspaceLocalConfig';

View file

@ -0,0 +1,33 @@
/**
* `workspaceCommand` domain (L6) workspace mutation command contract.
*
* Defines the `ISessionWorkspaceCommandService` that orchestrates session-level
* workspace mutations (`addAdditionalDir`): persisting workspace-local config
* when asked, updating `ISessionWorkspaceContext`, and mirroring the
* action's stdout into the main agent's context as a `local-command-stdout`
* injection so the agent observes the change. Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface AddAdditionalDirInput {
readonly path: string;
/** When `true` (default), persist the directory into `<projectRoot>/.kimi-code/local.toml`. */
readonly persist?: boolean;
}
export interface WorkspaceAdditionalDirsResult {
readonly projectRoot: string;
readonly configPath: string;
readonly additionalDirs: readonly string[];
readonly persisted: boolean;
}
export interface ISessionWorkspaceCommandService {
readonly _serviceBrand: undefined;
addAdditionalDir(input: AddAdditionalDirInput): Promise<WorkspaceAdditionalDirsResult>;
}
export const ISessionWorkspaceCommandService: ServiceIdentifier<ISessionWorkspaceCommandService> =
createDecorator<ISessionWorkspaceCommandService>('sessionWorkspaceCommandService');

View file

@ -0,0 +1,138 @@
/**
* `workspaceCommand` domain (L6) `ISessionWorkspaceCommandService` implementation.
*
* Coordinates session-level workspace mutations. `addAdditionalDir` persists
* the directory into the workspace-local config file when `persist` is true
* (`<projectRoot>/.kimi-code/local.toml`, through `IHostFileSystem`), updates
* `ISessionWorkspaceContext`, and mirrors the action's stdout into the main
* agent's context as a `local-command-stdout` injection (via
* `IAgentContextMemoryService` on the `main` handle from `agentLifecycle`).
* If the main agent does not exist yet, the injection is queued and flushed
* from the `onDidCreateMain` subscription. Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IBootstrapService } from '#/app/bootstrap';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import {
type AddAdditionalDirInput,
ISessionWorkspaceCommandService,
type WorkspaceAdditionalDirsResult,
} from './workspaceCommand';
import {
appendWorkspaceAdditionalDir,
normalizeAdditionalDirs,
readWorkspaceAdditionalDirs,
resolveWorkspaceAdditionalDirs,
type WorkspaceLocalDeps,
} from './workspaceLocalConfig';
export class SessionWorkspaceCommandService
extends Disposable
implements ISessionWorkspaceCommandService
{
declare readonly _serviceBrand: undefined;
private readonly pendingMainInjections: ContextMessage[] = [];
private mutationQueue: Promise<void> = Promise.resolve();
constructor(
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
) {
super();
this._register(
this.agents.onDidCreateMain((handle) => {
if (this.pendingMainInjections.length === 0) return;
const pending = this.pendingMainInjections.splice(0);
handle.accessor.get(IAgentContextMemoryService).append(...pending);
}),
);
}
async addAdditionalDir(input: AddAdditionalDirInput): Promise<WorkspaceAdditionalDirsResult> {
return this.enqueueMutation(() => this.applyAddAdditionalDir(input));
}
private async applyAddAdditionalDir(
input: AddAdditionalDirInput,
): Promise<WorkspaceAdditionalDirsResult> {
const persist = input.persist ?? true;
const deps: WorkspaceLocalDeps = { fs: this.hostFs, homeDir: this.bootstrap.homeDir };
if (persist) {
const persisted = await appendWorkspaceAdditionalDir(
deps,
this.workspace.workDir,
input.path,
);
const additionalDirs = normalizeAdditionalDirs([
...this.workspace.additionalDirs,
...persisted.additionalDirs,
]);
this.workspace.setAdditionalDirs(additionalDirs);
this.injectAdditionalDirAdded(input.path, true, persisted.configPath);
return {
projectRoot: persisted.projectRoot,
configPath: persisted.configPath,
additionalDirs,
persisted: true,
};
}
const workspace = await readWorkspaceAdditionalDirs(deps, this.workspace.workDir);
const resolved = await resolveWorkspaceAdditionalDirs(deps, this.workspace.workDir, [
input.path,
]);
const additionalDirs = normalizeAdditionalDirs([...this.workspace.additionalDirs, ...resolved]);
this.workspace.setAdditionalDirs(additionalDirs);
this.injectAdditionalDirAdded(input.path, false, workspace.configPath);
return {
projectRoot: workspace.projectRoot,
configPath: workspace.configPath,
additionalDirs,
persisted: false,
};
}
private enqueueMutation<T>(work: () => Promise<T>): Promise<T> {
const run = this.mutationQueue.then(work, work);
this.mutationQueue = run.then(() => undefined, () => undefined);
return run;
}
private injectAdditionalDirAdded(path: string, persisted: boolean, configPath: string): void {
const stdout = persisted
? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
: `Added workspace directory:\n ${path}\n For this session only`;
const text = `<local-command-stdout>\n${stdout.trim()}\n</local-command-stdout>`;
const message: ContextMessage = {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'injection', variant: 'local-command-stdout' },
};
const main = this.agents.getHandle(MAIN_AGENT_ID);
if (main !== undefined) {
main.accessor.get(IAgentContextMemoryService).append(message);
return;
}
this.pendingMainInjections.push(message);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionWorkspaceCommandService,
SessionWorkspaceCommandService,
InstantiationType.Delayed,
'workspaceCommand',
);

View file

@ -0,0 +1,318 @@
/**
* `workspaceCommand` domain (L6) workspace local-config file helpers.
*
* Reads and writes the `<projectRoot>/.kimi-code/local.toml` file that records
* additional workspace directories persisted across sessions. Pure IO
* functions over `IHostFileSystem` plus the host home directory; no scoped
* state. Ported from v1's `config/workspace-local.ts` with the Kaos primitive
* swapped for v2's host filesystem.
*/
import { dirname, isAbsolute, join, normalize, resolve } from 'pathe';
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
import { z } from 'zod';
import { ErrorCodes, KimiError } from '#/errors';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
const WorkspaceLocalTomlSchema = z.object({
workspace: z
.object({
additional_dir: z.array(z.string()),
})
.optional(),
});
type WorkspaceLocalToml = z.infer<typeof WorkspaceLocalTomlSchema>;
export interface WorkspaceLocalDeps {
readonly fs: IHostFileSystem;
readonly homeDir: string;
}
export interface WorkspaceAdditionalDirsLoadResult {
readonly projectRoot: string;
readonly configPath: string;
readonly additionalDirs: readonly string[];
}
interface WorkspaceLocalTomlFile {
readonly raw: Record<string, unknown>;
readonly parsed: WorkspaceLocalToml;
}
export async function readWorkspaceAdditionalDirs(
deps: WorkspaceLocalDeps,
workDir: string,
): Promise<WorkspaceAdditionalDirsLoadResult> {
const projectRoot = await findProjectRoot(deps, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const file = await readWorkspaceLocalToml(deps, configPath);
const additionalDirs = file?.parsed.workspace?.additional_dir;
if (additionalDirs === undefined) {
return { projectRoot, configPath, additionalDirs: [] };
}
return {
projectRoot,
configPath,
additionalDirs: await resolveAdditionalDirs(deps, projectRoot, additionalDirs),
};
}
export async function resolveWorkspaceAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): Promise<string[]> {
return resolveAdditionalDirs(deps, projectRoot, additionalDirs);
}
export async function appendWorkspaceAdditionalDir(
deps: WorkspaceLocalDeps,
workDir: string,
inputPath: string,
): Promise<WorkspaceAdditionalDirsLoadResult> {
const projectRoot = await findProjectRoot(deps, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const additionalDir = await resolveAdditionalDir(deps, workDir, inputPath);
const file = (await readWorkspaceLocalToml(deps, configPath)) ?? { raw: {}, parsed: {} };
const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? [];
const fileExistingDirs = resolveExistingAdditionalDirs(deps, projectRoot, fileAdditionalDirs);
if (hasSameAdditionalDir(fileExistingDirs, additionalDir)) {
return { projectRoot, configPath, additionalDirs: fileExistingDirs };
}
const workspace = cloneRecord(file.raw['workspace']);
workspace['additional_dir'] = [...fileExistingDirs, additionalDir];
file.raw['workspace'] = workspace;
await deps.fs.mkdir(dirname(configPath), { recursive: true });
await deps.fs.writeText(configPath, `${stringifyToml(file.raw)}\n`);
return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] };
}
export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] {
const seen = new Set<string>();
const normalizedDirs: string[] = [];
for (const additionalDir of additionalDirs) {
const normalized = normalize(additionalDir);
if (seen.has(normalized)) continue;
seen.add(normalized);
normalizedDirs.push(normalized);
}
return normalizedDirs;
}
function getWorkspaceLocalConfigPath(projectRoot: string): string {
return join(projectRoot, '.kimi-code', 'local.toml');
}
async function findProjectRoot(deps: WorkspaceLocalDeps, workDir: string): Promise<string> {
const initial = normalize(workDir);
let current = initial;
while (true) {
if (await pathExists(deps, join(current, '.git'))) return current;
const parent = dirname(current);
if (parent === current) return initial;
current = parent;
}
}
async function readWorkspaceLocalToml(
deps: WorkspaceLocalDeps,
configPath: string,
): Promise<WorkspaceLocalTomlFile | undefined> {
let text: string;
try {
text = await deps.fs.readText(configPath);
} catch (error: unknown) {
if (isPathMissing(error)) return undefined;
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Failed to read ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (text.trim().length === 0) return { raw: {}, parsed: {} };
let raw: unknown;
try {
raw = parseToml(text);
} catch (error: unknown) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Invalid TOML in ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (!isPlainObject(raw)) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid workspace local config in ${configPath}`);
}
return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) };
}
function parseWorkspaceLocalToml(raw: Record<string, unknown>): WorkspaceLocalToml {
try {
return WorkspaceLocalTomlSchema.parse(raw);
} catch (error: unknown) {
if (error instanceof z.ZodError) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, describeWorkspaceLocalValidationError(error), {
cause: error,
});
}
throw error;
}
}
function describeWorkspaceLocalValidationError(error: z.ZodError): string {
const issue = error.issues[0];
if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') {
return 'workspace.additional_dir must be an array of strings';
}
if (issue?.path[0] === 'workspace') return 'workspace must be a table';
return `Invalid workspace local config: ${error.message}`;
}
async function resolveAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): Promise<string[]> {
const resolvedDirs: string[] = [];
for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
const resolvedDir = await resolveAdditionalDir(deps, projectRoot, additionalDir);
if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
return resolvedDirs;
}
function resolveExistingAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): string[] {
const resolvedDirs: string[] = [];
for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
const resolvedDir = resolvePath(deps, projectRoot, additionalDir);
if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
return resolvedDirs;
}
async function resolveAdditionalDir(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDir: string,
): Promise<string> {
const normalizedInput = normalizeAdditionalDirInput(additionalDir);
const resolvedDir = resolvePath(deps, projectRoot, normalizedInput);
await assertDirectory(deps, resolvedDir);
return resolvedDir;
}
function normalizeAdditionalDirInput(additionalDir: string): string {
if (typeof additionalDir !== 'string') {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must be an array of strings',
);
}
const trimmed = additionalDir.trim();
if (trimmed.length === 0) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
return normalize(trimmed);
}
function resolvePath(deps: WorkspaceLocalDeps, projectRoot: string, additionalDir: string): string {
const expanded = expandHome(deps, additionalDir);
return isAbsolute(expanded) ? normalize(expanded) : resolve(projectRoot, expanded);
}
function expandHome(deps: WorkspaceLocalDeps, value: string): string {
if (value === '~') return deps.homeDir;
if (value.startsWith('~/')) return join(deps.homeDir, value.slice(2));
return value;
}
function hasSameAdditionalDir(dirs: readonly string[], target: string): boolean {
const normalizedTarget = normalize(target);
return dirs.some((dir) => normalize(dir) === normalizedTarget);
}
async function assertDirectory(deps: WorkspaceLocalDeps, filePath: string): Promise<void> {
let stat: Awaited<ReturnType<IHostFileSystem['stat']>>;
try {
stat = await deps.fs.stat(filePath);
} catch (error: unknown) {
if (isPathMissing(error)) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Failed to stat ${filePath}: ${describeError(error)}`,
{ cause: error },
);
}
if (!stat.isDirectory) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
}
async function pathExists(deps: WorkspaceLocalDeps, filePath: string): Promise<boolean> {
try {
await deps.fs.stat(filePath);
return true;
} catch {
return false;
}
}
function cloneRecord(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) return {};
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPathMissing(error: unknown): boolean {
const code = getErrorCode(error);
return code === 'ENOENT' || code === 'ENOTDIR';
}
function getErrorCode(error: unknown): unknown {
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
return (error as { code: unknown }).code;
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -17,6 +17,7 @@ export interface ISessionWorkspaceContext {
readonly workDir: string;
readonly additionalDirs: readonly string[];
setWorkDir(workDir: string): void;
setAdditionalDirs(dirs: readonly string[]): void;
resolve(rel: string): string;
isWithin(absPath: string): boolean;
assertAllowed(absPath: string, op: PathAccessOperation): string;

View file

@ -35,6 +35,10 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext
this._workDir = resolve(workDir);
}
setAdditionalDirs(dirs: readonly string[]): void {
this._additionalDirs = [...new Set(dirs.map((d) => resolve(d)))];
}
resolve(rel: string): string {
return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel);
}

View file

@ -16,6 +16,7 @@ export function stubWorkspaceContext(
workDir,
additionalDirs,
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => `${workDir}/${rel}`,
isWithin: () => true,
assertAllowed: (absPath) => absPath,

View file

@ -739,6 +739,9 @@ function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext {
setWorkDir: (nextWorkDir) => {
workDir = nextWorkDir;
},
setAdditionalDirs: (dirs) => {
additionalDirs = [...dirs];
},
resolve: (path) => path,
isWithin: () => true,
assertAllowed: (path) => path,

View file

@ -27,6 +27,7 @@ function stubWorkspace(): ISessionWorkspaceContext {
workDir: WORK_DIR,
additionalDirs: [],
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)),
isWithin: (abs) => {
const r = relative(WORK_DIR, abs);

View file

@ -55,6 +55,7 @@ function workspaceStub(workDir: string): {
setWorkDir: (dir: string) => {
current = dir;
},
setAdditionalDirs: () => {},
resolve: (rel: string) => rel,
isWithin: () => true,
assertAllowed: (p: string) => p,

View file

@ -74,6 +74,7 @@ function stubWorkspace(workDir = '/ws'): ISessionWorkspaceContext {
workDir,
additionalDirs: [],
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => resolve(workDir, rel),
isWithin: () => true,
assertAllowed: (absPath) => resolve(workDir, absPath),

View file

@ -0,0 +1,373 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { LifecycleScope } from '#/_base/di/scope';
import type { ServiceIdentifier } from '#/_base/di/instantiation';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { Emitter } from '#/_base/event';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IBootstrapService } from '#/app/bootstrap';
import { ErrorCodes, KimiError } from '#/errors';
import {
type HostDirEntry,
type HostFileStat,
IHostFileSystem,
} from '#/os/interface/hostFileSystem';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
import { ISessionContext, makeSessionContext } from '#/session/sessionContext';
import {
ISessionWorkspaceCommandService,
SessionWorkspaceCommandService,
} from '#/session/workspaceCommand';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { SessionWorkspaceContextService } from '#/session/workspaceContext';
import { stubContextMemory, type StubContextMemory } from '../contextMemory/stubs';
const WORK_DIR = '/repo/work';
const EXTRA_DIR = `${WORK_DIR}/extra`;
const DIR_A = `${WORK_DIR}/a`;
const DIR_B = `${WORK_DIR}/b`;
class MemoryHostFs implements IHostFileSystem {
declare readonly _serviceBrand: undefined;
readonly files = new Map<string, string>();
readonly dirs = new Set<string>();
readonly readsDuringPausedWrite: string[] = [];
private pausedWrites = 0;
private nextWritePause:
| {
readonly started: () => void;
readonly release: Promise<void>;
}
| undefined;
constructor(seedDirs: readonly string[] = []) {
for (const d of seedDirs) this.dirs.add(d);
}
async readText(path: string): Promise<string> {
if (this.pausedWrites > 0) this.readsDuringPausedWrite.push(path);
const text = this.files.get(path);
if (text === undefined) throw enoent(path);
return text;
}
async writeText(path: string, data: string): Promise<void> {
const pause = this.nextWritePause;
if (pause !== undefined) {
this.nextWritePause = undefined;
this.pausedWrites++;
pause.started();
try {
await pause.release;
} finally {
this.pausedWrites--;
}
}
this.files.set(path, data);
}
pauseNextWrite(): { readonly started: Promise<void>; readonly release: () => void } {
let started!: () => void;
let release!: () => void;
const startedPromise = new Promise<void>((resolve) => {
started = resolve;
});
const releasePromise = new Promise<void>((resolve) => {
release = resolve;
});
this.nextWritePause = { started, release: releasePromise };
return { started: startedPromise, release };
}
async readBytes(): Promise<Uint8Array> {
throw new Error('not implemented');
}
async writeBytes(): Promise<void> {
throw new Error('not implemented');
}
async *readLines(): AsyncGenerator<string> {
throw new Error('not implemented');
}
async createExclusive(): Promise<boolean> {
throw new Error('not implemented');
}
async stat(path: string): Promise<HostFileStat> {
if (this.files.has(path)) {
return { isFile: true, isDirectory: false, size: this.files.get(path)?.length ?? 0 };
}
if (this.dirs.has(path)) return { isFile: false, isDirectory: true, size: 0 };
throw enoent(path);
}
async readdir(): Promise<readonly HostDirEntry[]> {
throw new Error('not implemented');
}
async mkdir(path: string): Promise<void> {
this.dirs.add(path);
}
async remove(path: string): Promise<void> {
this.files.delete(path);
this.dirs.delete(path);
}
}
function enoent(path: string): NodeJS.ErrnoException {
const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException;
error.code = 'ENOENT';
return error;
}
interface AgentsStub extends IAgentLifecycleService {
readonly mainContext: StubContextMemory;
setMain(present: boolean): void;
}
function agentsStub(): AgentsStub {
const mainContext = stubContextMemory();
let mainPresent = false;
const mainCreated = new Emitter<IAgentScopeHandle>();
const mainHandle: IAgentScopeHandle = {
id: MAIN_AGENT_ID,
kind: LifecycleScope.Agent,
accessor: {
get: <T>(id: ServiceIdentifier<T>): T => {
if (id === IAgentContextMemoryService) return mainContext as unknown as T;
throw new Error(`unexpected service on main handle: ${String(id)}`);
},
},
dispose: () => {},
};
return {
_serviceBrand: undefined,
mainContext,
onDidCreate: () => ({ dispose: () => {} }),
onDidCreateMain: mainCreated.event,
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.reject(new Error('not implemented')),
ensureMcpReady: () => Promise.resolve(),
notifyMainCreated: (handle) => mainCreated.fire(handle),
fork: () => Promise.reject(new Error('not implemented')),
run: () => {
throw new Error('not implemented');
},
getHandle: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined),
list: () => [],
remove: () => Promise.resolve(),
setMain: (present) => {
mainPresent = present;
if (present) mainCreated.fire(mainHandle);
},
};
}
function bootstrapStub(): IBootstrapService {
return {
_serviceBrand: undefined,
homeDir: '/home/test',
} as IBootstrapService;
}
function sessionContext(workDir = WORK_DIR): ISessionContext {
return makeSessionContext({
sessionId: 'ses',
workspaceId: 'ws',
sessionDir: '/tmp/sessions/ws/ses',
sessionScope: 'sessions/ws/ses',
cwd: workDir,
});
}
function nextMacrotask(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
interface Harness {
readonly svc: ISessionWorkspaceCommandService;
readonly fs: MemoryHostFs;
readonly agents: AgentsStub;
readonly workspace: ISessionWorkspaceContext;
}
describe('SessionWorkspaceCommandService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
});
afterEach(() => {
disposables.dispose();
});
function build(
seedDirs: readonly string[],
mainPresent: boolean,
workDir = WORK_DIR,
gitDir = `${workDir}/.git`,
): Harness {
const fs = new MemoryHostFs([gitDir, workDir, ...seedDirs]);
const agents = agentsStub();
const ctx = sessionContext(workDir);
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(ISessionContext, ctx);
reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService);
reg.defineInstance(IBootstrapService, bootstrapStub());
reg.defineInstance(IHostFileSystem, fs);
reg.defineInstance(IAgentLifecycleService, agents);
reg.define(ISessionWorkspaceCommandService, SessionWorkspaceCommandService);
},
});
const workspace = ix.get(ISessionWorkspaceContext);
const svc = ix.get(ISessionWorkspaceCommandService);
agents.setMain(mainPresent);
return { svc, fs, agents, workspace };
}
it('persists the directory and injects a local-command-stdout message when main exists', async () => {
const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
const result = await svc.addAdditionalDir({ path: 'extra', persist: true });
expect(result.persisted).toBe(true);
expect(result.configPath).toBe(`${WORK_DIR}/.kimi-code/local.toml`);
expect(result.additionalDirs).toContain(EXTRA_DIR);
expect(workspace.additionalDirs).toContain(EXTRA_DIR);
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toContain('additional_dir');
expect(written).toContain(EXTRA_DIR);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content).toEqual([
{
type: 'text',
text: `<local-command-stdout>\nAdded workspace directory:\n extra\n Saved to:\n ${WORK_DIR}/.kimi-code/local.toml\n</local-command-stdout>`,
},
]);
expect(agents.mainContext.messages[0]?.origin).toEqual({
kind: 'injection',
variant: 'local-command-stdout',
});
});
it('does not persist and injects a session-only message when persist is false', async () => {
const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
const result = await svc.addAdditionalDir({ path: 'extra', persist: false });
expect(result.persisted).toBe(false);
expect(workspace.additionalDirs).toContain(EXTRA_DIR);
expect(fs.files.has(`${WORK_DIR}/.kimi-code/local.toml`)).toBe(false);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content).toEqual([
{
type: 'text',
text: '<local-command-stdout>\nAdded workspace directory:\n extra\n For this session only\n</local-command-stdout>',
},
]);
});
it('queues the injection until the main agent is created', async () => {
const { svc, agents } = build([EXTRA_DIR], false);
await svc.addAdditionalDir({ path: 'extra', persist: true });
expect(agents.mainContext.messages).toHaveLength(0);
agents.setMain(true);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Added workspace directory:'),
});
});
it('keeps the persisted config idempotent when the same dir is added twice', async () => {
const { svc, fs } = build([EXTRA_DIR], true);
await svc.addAdditionalDir({ path: 'extra', persist: true });
await svc.addAdditionalDir({ path: 'extra', persist: true });
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toBeDefined();
const matches = written?.match(new RegExp(EXTRA_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
expect(matches).toHaveLength(1);
});
it('serializes concurrent persisted additions so local.toml keeps both directories', async () => {
const { svc, fs, workspace } = build([DIR_A, DIR_B], true);
const pause = fs.pauseNextWrite();
const first = svc.addAdditionalDir({ path: 'a', persist: true });
await pause.started;
const second = svc.addAdditionalDir({ path: 'b', persist: true });
await nextMacrotask();
const overlappingReads = [...fs.readsDuringPausedWrite];
pause.release();
const [, secondResult] = await Promise.all([first, second]);
expect(overlappingReads).toEqual([]);
expect(secondResult.additionalDirs).toEqual([DIR_A, DIR_B]);
expect(workspace.additionalDirs).toEqual([DIR_A, DIR_B]);
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toContain(DIR_A);
expect(written).toContain(DIR_B);
});
it('resolves caller-relative dirs against the session workDir when project root is above it', async () => {
const projectRoot = '/repo/project';
const workDir = `${projectRoot}/apps/foo`;
const sharedDir = `${workDir}/shared`;
const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
const result = await svc.addAdditionalDir({ path: 'shared', persist: true });
expect(result.projectRoot).toBe(projectRoot);
expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
expect(result.additionalDirs).toEqual([sharedDir]);
expect(workspace.additionalDirs).toEqual([sharedDir]);
expect(fs.files.get(`${projectRoot}/.kimi-code/local.toml`)).toContain(sharedDir);
});
it('resolves session-only relative dirs against the session workDir when project root is above it', async () => {
const projectRoot = '/repo/project';
const workDir = `${projectRoot}/apps/foo`;
const sharedDir = `${workDir}/shared`;
const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
const result = await svc.addAdditionalDir({ path: 'shared', persist: false });
expect(result.projectRoot).toBe(projectRoot);
expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
expect(result.additionalDirs).toEqual([sharedDir]);
expect(workspace.additionalDirs).toEqual([sharedDir]);
expect(fs.files.has(`${projectRoot}/.kimi-code/local.toml`)).toBe(false);
});
it('rejects a relative path that does not resolve to an existing directory', async () => {
const { svc } = build([], true);
await expect(svc.addAdditionalDir({ path: 'missing', persist: true })).rejects.toSatisfy(
(error) => error instanceof KimiError && error.code === ErrorCodes.CONFIG_INVALID,
);
});
});

View file

@ -53,6 +53,7 @@ import {
IAgentToolState,
IAgentUsageService,
ISessionWorkspaceContext,
ISessionWorkspaceCommandService,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
@ -152,7 +153,10 @@ export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
'workspace:resolve': { service: ISessionWorkspaceContext, method: 'resolve', readonly: true },
'workspace:isWithin': { service: ISessionWorkspaceContext, method: 'isWithin', readonly: true },
'workspace:setWorkDir': { service: ISessionWorkspaceContext, method: 'setWorkDir' },
'workspace:addAdditionalDir': { service: ISessionWorkspaceContext, method: 'addAdditionalDir' },
'workspace:addAdditionalDir': {
service: ISessionWorkspaceCommandService,
method: 'addAdditionalDir',
},
'workspace:removeAdditionalDir': { service: ISessionWorkspaceContext, method: 'removeAdditionalDir' },
'fs:search': { service: ISessionFsService, method: 'search', readonly: true },