mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-26 00:54:36 +00:00
feat(agent-core-v2): add workspace add-dir command
This commit is contained in:
parent
b37345e5d5
commit
339022b375
17 changed files with 904 additions and 2 deletions
|
|
@ -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 |
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
11
packages/agent-core-v2/src/session/workspaceCommand/index.ts
Normal file
11
packages/agent-core-v2/src/session/workspaceCommand/index.ts
Normal 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';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export function stubWorkspaceContext(
|
|||
workDir,
|
||||
additionalDirs,
|
||||
setWorkDir: () => {},
|
||||
setAdditionalDirs: () => {},
|
||||
resolve: (rel) => `${workDir}/${rel}`,
|
||||
isWithin: () => true,
|
||||
assertAllowed: (absPath) => absPath,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function workspaceStub(workDir: string): {
|
|||
setWorkDir: (dir: string) => {
|
||||
current = dir;
|
||||
},
|
||||
setAdditionalDirs: () => {},
|
||||
resolve: (rel: string) => rel,
|
||||
isWithin: () => true,
|
||||
assertAllowed: (p: string) => p,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue