From 339022b37566ee198052147400aa83dbce2a5298 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 7 Jul 2026 12:35:49 +0800 Subject: [PATCH] feat(agent-core-v2): add workspace add-dir command --- .../agent-core-v2/docs/di-scope-domains.puml | 5 + .../agent-core-v2/docs/di-scope-domains.svg | 2 +- .../scripts/check-domain-layers.mjs | 7 + packages/agent-core-v2/src/index.ts | 1 + .../src/session/workspaceCommand/index.ts | 11 + .../workspaceCommand/workspaceCommand.ts | 33 ++ .../workspaceCommandService.ts | 138 +++++++ .../workspaceCommand/workspaceLocalConfig.ts | 318 +++++++++++++++ .../workspaceContext/workspaceContext.ts | 1 + .../workspaceContextService.ts | 4 + .../test/fileTools/stub-workspace-context.ts | 1 + .../permission-policy-service.test.ts | 3 + .../test/sessionFs/fsService.test.ts | 1 + .../test/skill/skillCatalog.test.ts | 1 + .../test/terminal/terminalService.test.ts | 1 + .../workspace-command.test.ts | 373 ++++++++++++++++++ packages/server-v2/src/transport/actionMap.ts | 6 +- 17 files changed, 904 insertions(+), 2 deletions(-) create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/index.ts create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts create mode 100644 packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 324ff55e0..6839add75 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -54,6 +54,7 @@ package "Session scope (per session)" #EAFAF1 { rectangle "agentLifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3 rectangle "interaction\nSession\n IInteractionService" as interaction #D5F5E3 rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3 + rectangle "workspaceCommand\nSession\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3 rectangle "sessionLog\nSession binding\n ILogService" as sessionLog #D5F5E3 rectangle "sessionSkillCatalog\nSession\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3 rectangle "sessionFs\nSession\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 diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index c53d526db..a2b79101b 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourcesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStateAgentIAgentToolStatetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applyusage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourcesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStateAgentIAgentToolStatetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applyusage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 505676b08..b244061fb 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3a72dd944..e74a33947 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/workspaceCommand/index.ts b/packages/agent-core-v2/src/session/workspaceCommand/index.ts new file mode 100644 index 000000000..8e15a44a0 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceCommand/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts new file mode 100644 index 000000000..70f85ec5c --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts @@ -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 `/.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; +} + +export const ISessionWorkspaceCommandService: ServiceIdentifier = + createDecorator('sessionWorkspaceCommandService'); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts new file mode 100644 index 000000000..139477513 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts @@ -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 + * (`/.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 = 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 { + return this.enqueueMutation(() => this.applyAddAdditionalDir(input)); + } + + private async applyAddAdditionalDir( + input: AddAdditionalDirInput, + ): Promise { + 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(work: () => Promise): Promise { + 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 = `\n${stdout.trim()}\n`; + 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', +); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts new file mode 100644 index 000000000..8aaa7a7c2 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts @@ -0,0 +1,318 @@ +/** + * `workspaceCommand` domain (L6) — workspace local-config file helpers. + * + * Reads and writes the `/.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; + +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; + readonly parsed: WorkspaceLocalToml; +} + +export async function readWorkspaceAdditionalDirs( + deps: WorkspaceLocalDeps, + workDir: string, +): Promise { + 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 { + return resolveAdditionalDirs(deps, projectRoot, additionalDirs); +} + +export async function appendWorkspaceAdditionalDir( + deps: WorkspaceLocalDeps, + workDir: string, + inputPath: string, +): Promise { + 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(); + 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 { + 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 { + 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): 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 { + 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 { + 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 { + let stat: Awaited>; + 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 { + try { + await deps.fs.stat(filePath); + return true; + } catch { + return false; + } +} + +function cloneRecord(value: unknown): Record { + if (!isPlainObject(value)) return {}; + return JSON.parse(JSON.stringify(value)) as Record; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPathMissing(error: unknown): boolean { + const code = getErrorCode(error); + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function getErrorCode(error: unknown): unknown { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined; + return (error as { code: unknown }).code; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts index 4694d68d8..0919627a0 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts @@ -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; diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index c0b5d5c3c..a84af00b9 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -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); } diff --git a/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts b/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts index a7be4a81e..adbc0faf5 100644 --- a/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts +++ b/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts @@ -16,6 +16,7 @@ export function stubWorkspaceContext( workDir, additionalDirs, setWorkDir: () => {}, + setAdditionalDirs: () => {}, resolve: (rel) => `${workDir}/${rel}`, isWithin: () => true, assertAllowed: (absPath) => absPath, diff --git a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts index 65832cfec..0051fee43 100644 --- a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts +++ b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts @@ -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, diff --git a/packages/agent-core-v2/test/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/sessionFs/fsService.test.ts index 8ecebb3cd..59960bbb4 100644 --- a/packages/agent-core-v2/test/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/sessionFs/fsService.test.ts @@ -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); diff --git a/packages/agent-core-v2/test/skill/skillCatalog.test.ts b/packages/agent-core-v2/test/skill/skillCatalog.test.ts index 7df173c31..93d7b05df 100644 --- a/packages/agent-core-v2/test/skill/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/skill/skillCatalog.test.ts @@ -55,6 +55,7 @@ function workspaceStub(workDir: string): { setWorkDir: (dir: string) => { current = dir; }, + setAdditionalDirs: () => {}, resolve: (rel: string) => rel, isWithin: () => true, assertAllowed: (p: string) => p, diff --git a/packages/agent-core-v2/test/terminal/terminalService.test.ts b/packages/agent-core-v2/test/terminal/terminalService.test.ts index e288cbd00..43912758c 100644 --- a/packages/agent-core-v2/test/terminal/terminalService.test.ts +++ b/packages/agent-core-v2/test/terminal/terminalService.test.ts @@ -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), diff --git a/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts b/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts new file mode 100644 index 000000000..f93661932 --- /dev/null +++ b/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts @@ -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(); + readonly dirs = new Set(); + readonly readsDuringPausedWrite: string[] = []; + private pausedWrites = 0; + private nextWritePause: + | { + readonly started: () => void; + readonly release: Promise; + } + | undefined; + + constructor(seedDirs: readonly string[] = []) { + for (const d of seedDirs) this.dirs.add(d); + } + + async readText(path: string): Promise { + 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 { + 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; readonly release: () => void } { + let started!: () => void; + let release!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const releasePromise = new Promise((resolve) => { + release = resolve; + }); + this.nextWritePause = { started, release: releasePromise }; + return { started: startedPromise, release }; + } + + async readBytes(): Promise { + throw new Error('not implemented'); + } + + async writeBytes(): Promise { + throw new Error('not implemented'); + } + + async *readLines(): AsyncGenerator { + throw new Error('not implemented'); + } + + async createExclusive(): Promise { + throw new Error('not implemented'); + } + + async stat(path: string): Promise { + 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 { + throw new Error('not implemented'); + } + + async mkdir(path: string): Promise { + this.dirs.add(path); + } + + async remove(path: string): Promise { + 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(); + + const mainHandle: IAgentScopeHandle = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: (id: ServiceIdentifier): 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 { + 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: `\nAdded workspace directory:\n extra\n Saved to:\n ${WORK_DIR}/.kimi-code/local.toml\n`, + }, + ]); + 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: '\nAdded workspace directory:\n extra\n For this session only\n', + }, + ]); + }); + + 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, + ); + }); +}); diff --git a/packages/server-v2/src/transport/actionMap.ts b/packages/server-v2/src/transport/actionMap.ts index 6120af5a3..a46b4e63c 100644 --- a/packages/server-v2/src/transport/actionMap.ts +++ b/packages/server-v2/src/transport/actionMap.ts @@ -53,6 +53,7 @@ import { IAgentToolState, IAgentUsageService, ISessionWorkspaceContext, + ISessionWorkspaceCommandService, IWorkspaceRegistry, } from '@moonshot-ai/agent-core-v2'; @@ -152,7 +153,10 @@ export const actionMap: Record> = { '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 },