From 479403e701edb1cf872e1e7562c18be1ada6beb8 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 15:33:19 +0800 Subject: [PATCH 1/6] chore(vscode): release 0.6.6 (#2401) * chore(vscode): release 0.6.6 * chore(vscode): release 0.6.7 * chore(vscode): fold the sign-in wording fix into 0.6.6 * chore(vscode): backfill the 0.6.5 changelog entry --- .changeset/vscode-login-waiting-wording.md | 5 ----- apps/vscode/CHANGELOG.md | 2 ++ 2 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 .changeset/vscode-login-waiting-wording.md diff --git a/.changeset/vscode-login-waiting-wording.md b/.changeset/vscode-login-waiting-wording.md deleted file mode 100644 index e93bd1f45..000000000 --- a/.changeset/vscode-login-waiting-wording.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kimi-code": patch ---- - -Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication". diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 0fca2c7a3..4aa5eb5e0 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -5,6 +5,7 @@ ### Patch Changes - [#2393](https://github.com/MoonshotAI/kimi-code/pull/2393) [`6d0a046`](https://github.com/MoonshotAI/kimi-code/commit/6d0a046488edda56219961b253c4787abae7a113) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix new users getting stranded on "Model setup required" with no way back to sign-in when the first login finishes authorization but fails to complete model setup; the screen now offers a path back to the sign-in page so login can be retried. +- [#2402](https://github.com/MoonshotAI/kimi-code/pull/2402) [`0f3b106`](https://github.com/MoonshotAI/kimi-code/commit/0f3b106c4260ad626f66bc5c457a535d3163f2bc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication". - Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]: - @moonshot-ai/kimi-code-sdk@0.15.0 @@ -13,6 +14,7 @@ ### Patch Changes +- [#1994](https://github.com/MoonshotAI/kimi-code/pull/1994) [`beeb964`](https://github.com/MoonshotAI/kimi-code/commit/beeb964393c8f9a38c2b1e2273e4415fc434b16d) Thanks [@RealKai42](https://github.com/RealKai42)! - Reduce webview streaming re-render churn: settled assistant messages no longer re-render on every streaming delta, and local images over 10MB are no longer inlined into the webview DOM. - Updated dependencies [[`ec88d35`](https://github.com/MoonshotAI/kimi-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893), [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50), [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6), [`e458323`](https://github.com/MoonshotAI/kimi-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace)]: - @moonshot-ai/kimi-code-sdk@0.14.0 From d8f455d6942280cce2c8a93795515494d4b9e231 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 15:39:17 +0800 Subject: [PATCH 2/6] docs(changelog): sync 0.31.0 from apps/kimi-code/CHANGELOG.md (#2404) * docs(changelog): sync 0.31.0 from apps/kimi-code/CHANGELOG.md * docs(changelog): polish 0.31.0 entries and note secondary model as experimental --- docs/en/release-notes/changelog.md | 15 +++++++++++++++ docs/zh/release-notes/changelog.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index a1b797fc8..84ba624a1 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,21 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.31.0 (2026-07-30) + +### Features + +- Support Markdown-defined custom agents on agent-core. +- Add the /secondary_model slash command to configure the secondary model used by subagents (experimental; enable it in /experiments first). +- Plugins can contribute custom agents, discovered automatically and available for sub-agent delegation. +- Plugins can contribute system prompt instructions through `systemPrompt` or `systemPromptPath` in `kimi.plugin.json`. + +### Bug Fixes + +- Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification. +- Fix sessions missing from the session picker when their cached metadata predates the archived flag. +- Fix request headers not being passed correctly on some requests. + ## 0.30.0 (2026-07-29) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 2b813a801..8dd7ad636 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,21 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.31.0(2026-07-30) + +### 新功能 + +- TUI 支持 Markdown 定义的自定义 Agent。 +- 新增 /secondary_model 斜杠命令,用于配置子 Agent 使用的辅助模型(实验性功能,需先在 /experiments 中开启)。 +- 插件可贡献自定义 Agent,自动发现并可用于子 Agent 委派。 +- 插件可贡献系统提示词,通过 `kimi.plugin.json` 中的 `systemPrompt` 或 `systemPromptPath` 声明。 + +### 修复 + +- 移除 TaskOutput 工具的阻塞式 `block`/`timeout` 等待。 +- 修复会话元数据缓存早于 archived 标记时会话选择器缺少会话的问题。 +- 修复部分请求未能正确传递请求头的问题。 + ## 0.30.0(2026-07-29) ### 新功能 From f1a3475ad5d6540447496701aa75fd4b035ecb28 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 30 Jul 2026 18:42:20 +0800 Subject: [PATCH 3/6] fix(agent-core-v2): write refresh results in one atomic config transition (#2410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-core-v2): write refresh results in one atomic config transition - add IConfigService.replaceSections: applies several domains in a single state transition — one disk write, one effective rebuild, change events fire only after all domains took effect - rework ProviderDiscoveryService to absorb the orchestrator's two-phase removeProvider/setConfig host contract into one replaceSections write, so the kosong registries never pass through a halfway-removed catalog - stop writing the env-synthesized __kimi_env__ slice to config; the bridge's event-driven sync carries it into the registries on its own - fixes sporadic "model is not configured" errors when starting kimi web, caused by the background refresh transiently clearing the model catalog while the first session was being created * fix(agent-core-v2): stage replaceSections writes before mutating raw config Validate and strip every domain into a staged copy of the raw/memory layer first, then swap it in only after the whole batch succeeds — previously a later domain failing validation left earlier domains already applied to this.raw/this.memory while the call reported failure, exposing a partially applied user layer to inspect() and future merges. --- .changeset/fuzzy-pandas-refresh.md | 5 + .../agent-core-v2/src/app/config/config.ts | 13 ++ .../src/app/config/configService.ts | 45 ++++- .../src/app/kosongConfig/discoveryService.ts | 116 ++++++------- .../test/app/config/config.test.ts | 156 +++++++++++++++++- .../test/app/kosongConfig/discovery.test.ts | 147 +++++++++++++---- packages/agent-core-v2/test/kosong/stubs.ts | 13 ++ 7 files changed, 396 insertions(+), 99 deletions(-) create mode 100644 .changeset/fuzzy-pandas-refresh.md diff --git a/.changeset/fuzzy-pandas-refresh.md b/.changeset/fuzzy-pandas-refresh.md new file mode 100644 index 000000000..ecd821e6f --- /dev/null +++ b/.changeset/fuzzy-pandas-refresh.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sporadic "model is not configured" errors when starting kimi web, caused by the background provider-model refresh transiently clearing the model catalog while the first session was being created. diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 6c51b63a7..d2d9a8a29 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -203,6 +203,19 @@ export interface IConfigService { getAll(): ResolvedConfig; set(domain: string, patch: unknown, target?: ConfigTarget): Promise; replace(domain: string, value: unknown, target?: ConfigTarget): Promise; + /** + * Replaces several sections in ONE state transition: every domain's raw + * value is updated first, the document is persisted with a single disk + * write, and the effective view is rebuilt once — change events fire only + * after all domains have taken effect, so a reader can never observe a + * half-applied multi-section write. A domain mapped to `undefined` is + * cleared (same as `replace(domain, undefined)`). Domain application order + * follows the `sections` key order. + */ + replaceSections( + sections: Readonly>, + target?: ConfigTarget, + ): Promise; reload(): Promise; diagnostics(): readonly ConfigDiagnostic[]; } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 43675a718..77a46b2c4 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -354,6 +354,43 @@ export class ConfigService extends Disposable implements IConfigService { }); } + async replaceSections( + sections: Readonly>, + target: ConfigTarget = ConfigTarget.User, + ): Promise { + await this.ready; + const domains = Object.keys(sections); + if (domains.length === 0) return; + if (target === ConfigTarget.Memory) { + const staged: ResolvedConfig = { ...this.memory }; + for (const domain of domains) { + const value = sections[domain]; + if (value === undefined) { + delete staged[domain]; + } else { + staged[domain] = this.registry.validate(domain, value); + } + } + this.memory = staged; + this.commit('set', domains); + return; + } + await this.enqueueStateTransition(async () => { + const staged: ResolvedConfig = { ...this.raw }; + for (const domain of domains) { + const stripped = this.stripEnv(domain, sections[domain]); + if (stripped === undefined) { + delete staged[domain]; + } else { + staged[domain] = this.registry.validate(domain, stripped); + } + } + this.raw = staged; + await this.persistDomains(domains); + this.rebuildEffective('set', domains); + }); + } + private stripEnv(domain: string, value: unknown): unknown { let result = value; const section = this.registry.getSection(domain); @@ -565,7 +602,13 @@ export class ConfigService extends Disposable implements IConfigService { } private async persist(domain: string): Promise { - applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); + await this.persistDomains([domain]); + } + + private async persistDomains(domains: readonly string[]): Promise { + for (const domain of domains) { + applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); + } await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake); } } diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 3414f9763..f41041de8 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -3,10 +3,11 @@ * * Owns the all-provider model refresh: delegates to the shared * `@moonshot-ai/kimi-code-oauth` orchestrator (managed OAuth + open - * platforms + custom registries), applies the discovered providers/models - * to kosong's in-memory registries (the persistence bridge writes them back - * to config), and publishes `event.model_catalog.changed` on change. Bound - * at App scope. + * platforms + custom registries), writes the discovered providers/models + * into config through ONE atomic `replaceSections` transition (the + * persistence bridge then syncs them into kosong's in-memory registries), + * and publishes `event.model_catalog.changed` on change. Bound at App + * scope. * * `modelSource: 'static'` short-circuits refresh: a provider whose effective * model source is `static` (config-declared, or declared by its vendor @@ -18,14 +19,18 @@ * refresh them nor drop them (or a default model pointing at them). * * Two write-path details preserve the legacy semantics exactly: - * - Registry replaces preserve the entries the orchestrator could not see: - * the static exclusion AND the config-file-external entries (the - * env-synthesized `__kimi_env__` slice), which the orchestrator's - * user-value view does not contain. - * - `defaultModel` / `thinking` stay direct `config.replace` writes (like - * the OAuth flows): the env overlay may pin the runtime default to the - * env-synthesized model, and only the config effective view knows that — - * the bridge then syncs the effective pointer into the registry. + * - The orchestrator's two-phase host contract (removeProvider, then + * setConfig) is absorbed into a single atomic write: the removal is + * computed in memory only (`shapeWithoutProvider`), because the patch's + * full providers/models records already express it. The runtime + * registries therefore never pass through a halfway-removed state — that + * intermediate state was the source of the "provider/model not + * configured" startup race against profile binding. + * - The env-synthesized `__kimi_env__` slice is never written to config: + * it lives in the effective overlay, and the bridge's event-driven sync + * carries it into the registries on its own. `defaultModel` / `thinking` + * also go through config (like the OAuth flows), since the env overlay + * may pin the runtime default and only the config effective view knows. * * Credential detection goes through the provider-definition registry * (`resolveProviderEndpoint` against the provider's config env bag), not a @@ -47,7 +52,7 @@ import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; import { ModelCatalogErrors } from '#/kosong/model/errors'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; +import { type ModelRecord } from '#/kosong/model/model'; import { IProviderService, type ModelSource, @@ -88,7 +93,6 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { private refreshChain: Promise = Promise.resolve(); constructor( - @IModelService private readonly modelService: IModelService, @IProviderService private readonly providerService: IProviderService, @IConfigService private readonly config: IConfigService, @IOAuthService private readonly oauth: IOAuthService, @@ -188,7 +192,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { private buildRefreshHost(exclusion: StaticExclusion): RefreshProviderHost { return { getConfig: async () => this.readUserConfigShape(exclusion), - removeProvider: (providerId) => this.removeProviderForRefresh(providerId), + removeProvider: (providerId) => this.shapeWithoutProvider(providerId), setConfig: (patch) => this.applyRefreshPatch(patch, exclusion), resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef), userAgent: this.hostRequestHeaders.headers['User-Agent'], @@ -212,23 +216,16 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { } /** - * The registry entries the orchestrator's user-value view cannot see (the - * env-synthesized slice): preserved verbatim across every registry - * replace, or a refresh would drop the runtime env model/provider. + * The orchestrator's host contract is two-phase (removeProvider, then + * setConfig) because its original merge-semantics host could not delete + * keys through a patch. This host writes with replace semantics and the + * patch always carries the FULL providers/models records, so the removal + * is already expressed by the patch itself — computing it here in memory + * keeps the runtime registries untouched until the single atomic write in + * {@link applyRefreshPatch} (a halfway-removed catalog is what used to + * produce the "provider/model not configured" startup race). */ - private syntheticProviders( - userProviders: Readonly>, - ): Record { - return withoutKeys(this.providerService.list(), userProviders); - } - - private syntheticModels( - userModels: Readonly>, - ): Record { - return withoutKeys(this.modelService.list(), userModels); - } - - private async removeProviderForRefresh(providerId: string): Promise { + private shapeWithoutProvider(providerId: string): Promise { const current = this.readUserConfigShape(); const providers = current.providers as Record; const restProviders = Object.fromEntries( @@ -238,16 +235,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { const restModels = Object.fromEntries( Object.entries(models).filter(([, record]) => record.provider !== providerId), ); - await this.providerService.replaceAll({ - ...this.syntheticProviders(providers), - ...restProviders, - }); - await this.modelService.replaceAll({ ...this.syntheticModels(models), ...restModels }); - return { + return Promise.resolve({ ...current, providers: restProviders, models: restModels, - } as ManagedKimiConfigShape; + } as ManagedKimiConfigShape); } private async applyRefreshPatch( @@ -258,56 +250,50 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; const userModels = this.config.inspect>(MODELS_SECTION).userValue ?? {}; + // All four sections land in ONE config transition: a single disk write, + // then one effective rebuild whose change events synchronously push the + // new records into the kosong registries — no reader can observe a + // half-applied refresh. The env-synthesized slice (`__kimi_env__` etc.) + // is NOT written here: it lives in the effective overlay, and the + // bridge's event-driven sync carries it into the registries on its own. + const sections: Record = {}; if (patch.providers !== undefined) { - await this.providerService.replaceAll({ - ...this.syntheticProviders(userProviders), + sections[PROVIDERS_SECTION] = { ...exclusion.providers, ...patch.providers, - }); + }; } if (patch.models !== undefined) { - await this.modelService.replaceAll({ - ...this.syntheticModels(userModels), + sections[MODELS_SECTION] = { ...exclusion.models, // The orchestrator's alias shape is a structural superset of // ModelRecord at runtime (its protocol union additionally allows // vendor spellings the records never actually carry); the legacy // config.write path took `unknown`, so cast here. ...(patch.models as Record), - }); + }; } // The refresh orchestrator always sends all four keys, so key presence is // the write intent and an explicit `undefined` means CLEAR, not "leave - // alone". `set()` cannot express that — its deepMerge resolves an - // undefined patch back to the base value — so these go through `replace`, - // which deletes the section on undefined. Otherwise a default model (and - // its thinking setting) whose alias the upstream dropped would dangle in - // the user config forever. + // alone" — `replaceSections` deletes the section on undefined. Otherwise + // a default model (and its thinking setting) whose alias the upstream + // dropped would dangle in the user config forever. // // Exception: when the user's default points at a statically-sourced model // the orchestrator could not see, its clamp/restore logic would silently // clear or re-point the selection (and its thinking) — restore both. - // - // `defaultModel` / `thinking` go through config directly (not the - // registry): the env overlay may pin the runtime default, and only the - // config effective view knows — the bridge syncs the effective pointer - // into the registry afterwards. const restoreDefault = exclusion.defaultModel !== undefined; if ('defaultModel' in patch) { - await this.config.replace( - DEFAULT_MODEL_SECTION, - restoreDefault ? exclusion.defaultModel : patch.defaultModel, - ); + sections[DEFAULT_MODEL_SECTION] = restoreDefault + ? exclusion.defaultModel + : patch.defaultModel; } if ('thinking' in patch) { - await this.config.replace( - THINKING_SECTION, - restoreDefault ? exclusion.thinking : patch.thinking, - ); + sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking; } - // The writes above landed in the registries / config; compute the - // post-patch shape in memory (re-reading config would race the bridge's - // asynchronous persist of the registry changes). + await this.config.replaceSections(sections); + // The write above landed in config (and, through the bridge's synchronous + // event sync, the registries); compute the post-patch shape in memory. return { providers: patch.providers !== undefined diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index fe51e6af4..f6842a525 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -12,7 +12,7 @@ import type { ToolCall } from '#/kosong/contract/message'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile'; import { Error2, ErrorCodes, toErrorPayload } from '#/errors'; @@ -24,7 +24,7 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigRegistry, IConfigService } from '#/app/config/config'; +import { ConfigTarget, IConfigRegistry, IConfigService } from '#/app/config/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import '#/app/cron/configSection'; @@ -46,7 +46,9 @@ import { type LoopControl, } from '#/agent/loop/configSection'; import { + DEFAULT_MODEL_SECTION, MODELS_SECTION, + PROVIDERS_SECTION, SECONDARY_MODEL_EFFORT_ENV, SECONDARY_MODEL_ENV, SECONDARY_MODEL_SECTION, @@ -1889,3 +1891,153 @@ describe('ConfigService thinking effort max migration', () => { disposables.dispose(); }); }); + +describe('ConfigService replaceSections', () => { + // Top-level keys must precede every [table] header in TOML. + const SEED_TOML = [ + 'default_model = "acme/m1"', + '', + '[providers.acme]', + 'type = "openai"', + 'api_key = "sk-acme"', + '', + '[models."acme/m1"]', + 'provider = "acme"', + 'model = "m1"', + 'max_context_size = 1000', + '', + '[thinking]', + 'enabled = true', + '', + ].join('\n'); + + async function createSectionsConfig(toml = SEED_TOML) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg-replace-sections')); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + const store = ix.get(IAtomicTomlDocumentStore); + return { config, disposables, store, storage }; + } + + it('applies every domain in one transition with a single disk write, clearing undefined domains', async () => { + const { config, disposables, store } = await createSectionsConfig(); + const setSpy = vi.spyOn(store, 'set'); + + await config.replaceSections({ + [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, + [MODELS_SECTION]: { 'acme/m2': { provider: 'acme', model: 'm2', maxContextSize: 2000 } }, + [DEFAULT_MODEL_SECTION]: undefined, + [THINKING_SECTION]: undefined, + }); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme-2' }, + }); + expect(config.get>(MODELS_SECTION)).toEqual({ + 'acme/m2': { provider: 'acme', model: 'm2', maxContextSize: 2000 }, + }); + expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined(); + expect(config.get(THINKING_SECTION)).toEqual({}); + expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); + // `stripThinkingEnv` maps a clear to `{}` (`{...undefined}`), so the user + // layer collapses to an empty object instead of disappearing — the + // long-standing `replace(domain, undefined)` behavior, unchanged here. + expect(config.inspect(THINKING_SECTION).userValue).toEqual({}); + + disposables.dispose(); + }); + + it('fires change events only after all domains have taken effect', async () => { + const { config, disposables } = await createSectionsConfig(); + const domains: string[] = []; + let snapshotDuringFirstEvent: + | { providers: unknown; models: unknown; defaultModel: unknown; thinking: unknown } + | undefined; + config.onDidSectionChange((e) => { + domains.push(e.domain); + snapshotDuringFirstEvent ??= { + providers: config.get(PROVIDERS_SECTION), + models: config.get(MODELS_SECTION), + defaultModel: config.get(DEFAULT_MODEL_SECTION), + thinking: config.get(THINKING_SECTION), + }; + }); + + await config.replaceSections({ + [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, + [MODELS_SECTION]: { 'acme/m2': { provider: 'acme', model: 'm2', maxContextSize: 2000 } }, + [DEFAULT_MODEL_SECTION]: undefined, + [THINKING_SECTION]: undefined, + }); + + // Every event — including the very first one — already observes the fully + // applied state; no listener can catch the write half-applied. (The + // cleared thinking section still resolves to its schema default `{}`.) + expect(snapshotDuringFirstEvent).toEqual({ + providers: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, + models: { 'acme/m2': { provider: 'acme', model: 'm2', maxContextSize: 2000 } }, + defaultModel: undefined, + thinking: {}, + }); + expect([...domains].sort()).toEqual( + [PROVIDERS_SECTION, MODELS_SECTION, DEFAULT_MODEL_SECTION, THINKING_SECTION].sort(), + ); + + disposables.dispose(); + }); + + it('supports the memory target without touching the persisted user layer', async () => { + const { config, disposables, store } = await createSectionsConfig(); + const setSpy = vi.spyOn(store, 'set'); + + await config.replaceSections( + { [THINKING_SECTION]: { enabled: false, effort: 'low' } }, + ConfigTarget.Memory, + ); + + expect(setSpy).not.toHaveBeenCalled(); + expect(config.get(THINKING_SECTION)).toEqual({ + enabled: false, + effort: 'low', + }); + expect(config.inspect(THINKING_SECTION).userValue).toEqual({ enabled: true }); + + disposables.dispose(); + }); + + it('leaves the user layer untouched when a later domain fails validation', async () => { + const { config, disposables, store } = await createSectionsConfig(); + const setSpy = vi.spyOn(store, 'set'); + + // Providers is applied first in key order and validates fine; thinking + // then fails schema validation (`enabled` must be a boolean). The batch + // must reject with NO observable partial application. + await expect( + config.replaceSections({ + [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, + [THINKING_SECTION]: { enabled: 'yes' }, + }), + ).rejects.toThrow(); + + expect(setSpy).not.toHaveBeenCalled(); + expect(config.inspect>(PROVIDERS_SECTION).userValue).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme' }, + }); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme' }, + }); + expect(config.inspect(THINKING_SECTION).userValue).toEqual({ enabled: true }); + + disposables.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index 7b2430ee4..7d4d69a91 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -8,12 +8,16 @@ * at them all survive; * - concurrent refreshes serialize (never overlap); * - custom-registry fetches carry the host `User-Agent`; - * - provider/model patches land in kosong's in-memory registries via - * `replaceAll` (the persistence bridge writes them back to config), - * merging discovered aliases into user-owned provider records, while - * `defaultModel` / `thinking` still go through `config.replace` directly — - * restoring a surviving default selection and CLEARing a default (plus its - * thinking) whose alias the upstream dropped, never a dangling `set`; + * - provider/model patches land in config through ONE atomic + * `replaceSections` transition (the persistence bridge then syncs them + * into the registries), merging discovered aliases into user-owned + * provider records, while `defaultModel` / `thinking` ride the same + * transition — restoring a surviving default selection and CLEARing a + * default (plus its thinking) whose alias the upstream dropped, never a + * dangling `set`; + * - the two-phase orchestrator host contract (removeProvider, then + * setConfig) never exposes a halfway-removed catalog: the registries + * stay untouched until the single atomic write; * - the `[modelCatalog]` config section self-registers and validates. */ @@ -22,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createScopedTestHost } from '#/_base/di/test'; import { isError2 } from '#/_base/errors/errors'; +import { ILogService, type LogPayload } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; @@ -29,18 +34,18 @@ import { IEventService } from '#/app/event/event'; import { IProviderDiscoveryService } from '#/app/kosongConfig/discovery'; import '#/app/kosongConfig/discoveryService'; import { MODEL_CATALOG_SECTION } from '#/app/kosongConfig/configSection'; +import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; +import '#/app/kosongConfig/kosongConfigService'; import '#/kosong/model/errors'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IModelService, type ModelRecord, - type ModelsSection, } from '#/kosong/model/model'; import '#/kosong/model/modelService'; import { IProviderService, type ProviderConfig, - type ProvidersSection, } from '#/kosong/provider/provider'; import '#/kosong/provider/providerService'; import '#/kosong/provider/providers/kimi/kimi.contrib'; @@ -61,37 +66,50 @@ function stubEvents(): IEventService & { published: Array<{ type: string; payloa } as unknown as IEventService & { published: Array<{ type: string; payload: unknown }> }; } -function createHost( +function stubLogService(): ILogService { + return { + _serviceBrand: undefined, + level: 'debug', + setLevel: () => {}, + flush: async () => {}, + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => { + throw new Error('child loggers are not used by KosongConfigService'); + }, + } satisfies ILogService; +} + +async function createHost( sections: Record = {}, oauth: IOAuthService = stubOAuthService(), -): { +): Promise<{ host: ReturnType; config: StubConfigService; events: ReturnType; discovery: IProviderDiscoveryService; providers: IProviderService; models: IModelService; -} { +}> { const config = new StubConfigService(sections); const events = stubEvents(); const host = createScopedTestHost([ [IConfigService, config], [IOAuthService, oauth], [IEventService, events], + [ILogService, stubLogService()], [IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })], ]); - // Seed the in-memory registries from the fixture sections (the persistence - // bridge that normally does this is not instantiated here). const providers = host.app.accessor.get(IProviderService); - providers.loadAll( - (sections['providers'] ?? {}) as ProvidersSection, - sections['defaultProvider'] as string | undefined, - ); const models = host.app.accessor.get(IModelService); - models.loadAll( - (sections['models'] ?? {}) as ModelsSection, - sections['defaultModel'] as string | undefined, - ); + // The real persistence bridge (DI-activated): refresh writes land in + // config, and the bridge's config → kosong sync is what carries them into + // the registries (exactly like production). Await its initialization so + // the event subscriptions are in place before the test refreshes. + const bridge = host.app.accessor.get(IKosongConfigService); + await bridge.ready; return { host, config, @@ -125,7 +143,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { it('answers scoped refreshes of static providers with unchanged and no I/O', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - const { host, discovery } = createHost(staticSections); + const { host, discovery } = await createHost(staticSections); try { const result = await discovery.refreshProviderModels({ providerId: 'static-p' }); expect(result).toEqual({ changed: [], unchanged: ['static-p'], failed: [] }); @@ -136,7 +154,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { }); it('returns an empty result when nothing is refreshable', async () => { - const { host, discovery, events } = createHost(staticSections); + const { host, discovery, events } = await createHost(staticSections); try { const result = await discovery.refreshProviderModels({ scope: 'all' }); expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); @@ -164,7 +182,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery, events, providers, models } = createHost({ + const { host, config, discovery, events, providers, models } = await createHost({ providers: { ...staticProviders, acme: { @@ -207,7 +225,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { }); it('throws provider.not_found for an unknown scoped provider', async () => { - const { host, discovery } = createHost(staticSections); + const { host, discovery } = await createHost(staticSections); try { await expect(discovery.refreshProviderModels({ providerId: 'missing' })).rejects.toSatisfy( (error) => isError2(error) && error.code === 'provider.not_found', @@ -220,7 +238,7 @@ describe('refreshProviderModels modelSource short-circuit', () => { describe('refreshProviderModels write behavior', () => { it('serializes concurrent runs so they never overlap', async () => { - const { host, discovery } = createHost( + const { host, discovery } = await createHost( { providers: { [KIMI_CODE_PROVIDER_NAME]: { @@ -287,7 +305,7 @@ describe('refreshProviderModels write behavior', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, discovery } = createHost({ + const { host, discovery } = await createHost({ providers: { acme: { type: 'openai', @@ -337,7 +355,7 @@ describe('refreshProviderModels write behavior', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery, events, providers, models } = createHost({ + const { host, config, discovery, events, providers, models } = await createHost({ providers: { 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, }, @@ -397,7 +415,7 @@ describe('refreshProviderModels write behavior', () => { ); vi.stubGlobal('fetch', fetchMock); - const { host, config, discovery, models } = createHost({ + const { host, config, discovery, models } = await createHost({ providers: { 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, }, @@ -421,8 +439,8 @@ describe('refreshProviderModels write behavior', () => { ]); // The dropped alias was the default: an explicit undefined in the patch // must clear the section instead of leaving the default dangling. It has - // to go through `replace` — `set()`'s deepMerge would resolve undefined - // back to the stale base value. + // to go through a replacing write — `set()`'s deepMerge would resolve + // undefined back to the stale base value. expect(config.get('defaultModel')).toBeUndefined(); expect(config.get('thinking')).toBeUndefined(); const modelRecords = models.list(); @@ -432,6 +450,73 @@ describe('refreshProviderModels write behavior', () => { host.dispose(); } }); + + it('never exposes a halfway-removed catalog: the registries stay untouched until the single atomic write', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + acme: { + id: 'acme', + name: 'Acme', + api: 'https://acme.example.test/v1', + type: 'openai', + models: { m2: { id: 'm2', name: 'M2' } }, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const { host, config, discovery, providers, models } = await createHost({ + providers: { + acme: { + type: 'openai', + apiKey: 'sk-acme', + source: { + kind: 'apiJson', + url: 'https://registry.example.test/api.json', + apiKey: 'sk-registry', + }, + }, + }, + models: { + 'acme/m1': { provider: 'acme', model: 'm1', maxContextSize: 1000 }, + }, + defaultModel: 'acme/m1', + }); + try { + // The orchestrator's host contract is two-phase (removeProvider, then + // setConfig). By the time the atomic write happens, the removal phase + // must NOT have touched the runtime registries — a reader (e.g. a + // profile bind racing the refresh) only ever sees the old catalog or + // the new one, never a half-removed one. + let seenDuringWrite: { providers: readonly string[]; models: readonly string[] } | undefined; + const originalReplaceSections = config.replaceSections.bind(config); + vi.spyOn(config, 'replaceSections').mockImplementation(async (sections) => { + seenDuringWrite = { + providers: Object.keys(providers.list()), + models: Object.keys(models.list()), + }; + await originalReplaceSections(sections); + }); + + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result.failed).toEqual([]); + expect(seenDuringWrite).toEqual({ providers: ['acme'], models: ['acme/m1'] }); + expect(vi.mocked(config.replaceSections).mock.calls.length).toBe(1); + // After the write, config and the registries converge on the new state: + // the dropped alias (and its default selection) is gone everywhere. + expect(providers.list()['acme']).toBeDefined(); + expect(models.list()['acme/m2']).toBeDefined(); + expect(models.list()['acme/m1']).toBeUndefined(); + expect(config.get('defaultModel')).toBeUndefined(); + } finally { + host.dispose(); + } + }); }); describe('modelCatalog config section', () => { diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 7ab26a4a0..50b6262c4 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -69,6 +69,19 @@ export class StubConfigService implements IConfigService { return Promise.resolve(); } + replaceSections(sections: Readonly>): Promise { + for (const [domain, value] of Object.entries(sections)) { + const previousValue = this._values.get(domain); + if (value === undefined) { + this._values.delete(domain); + } else { + this._values.set(domain, value); + } + this._onDidChange.fire({ domain, source: 'set', value, previousValue }); + } + return Promise.resolve(); + } + /** * Mutate a section WITHOUT firing the change event — simulates a config * write that bypasses the services' change events (the cache-invalidation From 5c0ec2938ac3a01624b6503e5e5df80c9b08f46a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 20:01:57 +0800 Subject: [PATCH 4/6] fix(web): upgrade markstream-vue to 1.0.9-beta.1 and enable Monaco code highlighting (#2415) * fix(web): upgrade markstream-vue to 1.0.9-beta.1 and enable Monaco code highlighting * fix(nix): update pnpm deps hash for markstream-vue 1.0.9-beta.1 --- .changeset/kimi-web-markstream-monaco.md | 5 + apps/kimi-web/package.json | 5 +- .../kimi-web/src/components/chat/Markdown.vue | 10 +- flake.nix | 2 +- pnpm-lock.yaml | 240 +++++++++++++++++- 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 .changeset/kimi-web-markstream-monaco.md diff --git a/.changeset/kimi-web-markstream-monaco.md b/.changeset/kimi-web-markstream-monaco.md new file mode 100644 index 000000000..833432d49 --- /dev/null +++ b/.changeset/kimi-web-markstream-monaco.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 4245ec872..87d0a2041 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -18,10 +18,13 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "katex": "^0.17.0", - "markstream-vue": "^1.0.7", + "markstream-vue": "^1.0.9-beta.1", "mermaid": "^11.15.0", + "monaco-editor": "^0.55.1", "shiki": "^4.3.0", + "stream-diffs": "^0.0.2", "stream-markdown": "^0.0.16", + "stream-monaco": "^0.0.49", "vue": "^3.5.35", "vue-i18n": "^11.4.5" }, diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index 42535be66..25a9936c0 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -327,7 +327,14 @@ const CODE_DARK_THEME = 'github-dark'; // blank placeholders. Pinning `loading` to false drops the skeleton entirely: // the block renders its plain-text fallback immediately and shiki upgrades it to // the highlighted version when the highlighter is ready. Streaming blocks are -// unaffected (their `stream` is true, so the skeleton gate was already false). +// unaffected (their `stream` is true, so the skeleton gate was already +// false). +// Chat code blocks show no gutter: line numbers eat 3+ characters of reading +// width and every chat block starts at line 1 anyway. stream-diffs derives its +// gutter from `lineNumbers === false` (boolean, not monaco's 'off' string). +// This rides inside codeBlockProps because markstream 1.0.7 only forwards the +// top-level codeBlockMonacoOptions to the 'monaco' renderer kind — the 'shiki' +// kind's props object omits it, while codeBlockProps reach the same component. const codeBlockProps = { showHeader: true, showCopyButton: true, @@ -336,6 +343,7 @@ const codeBlockProps = { showCollapseButton: false, showFontSizeButtons: false, loading: false, + monacoOptions: { lineNumbers: false }, }; // Root cause for the "large session turns into code skeletons" failure: diff --git a/flake.nix b/flake.nix index 81f0aa0fe..0f3c13449 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-bL1AaInlb8dE+ua7a6llvQWkibEwEzfI3oQW5IOpX6I="; + hash = "sha256-FNxf3Zpr+s6ubtLIqXCkVgecBGejGM4hovUNVRnNoX4="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6276af942..980988e3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,17 +218,26 @@ importers: specifier: ^0.17.0 version: 0.17.0 markstream-vue: - specifier: ^1.0.7 - version: 1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) + specifier: ^1.0.9-beta.1 + version: 1.0.9-beta.1(katex@0.17.0)(mermaid@11.15.0)(stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)))(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(stream-monaco@0.0.49(monaco-editor@0.55.1))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) mermaid: specifier: ^11.15.0 version: 11.15.0 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 shiki: specifier: ^4.3.0 version: 4.3.0 + stream-diffs: + specifier: ^0.0.2 + version: 0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)) stream-markdown: specifier: ^0.0.16 version: 0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)) + stream-monaco: + specifier: ^0.0.49 + version: 0.0.49(monaco-editor@0.55.1) vue: specifier: ^3.5.35 version: 3.5.35(typescript@6.0.2) @@ -2804,6 +2813,36 @@ packages: cpu: [x64] os: [win32] + '@pierre/diffs@1.2.12': + resolution: {integrity: sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@1.1.0': + resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} + engines: {vscode: ^1.0.0} + + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + peerDependencies: + '@pierre/theme': ^1.1.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -4061,9 +4100,16 @@ packages: resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==} engines: {node: '>=20'} + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@2.5.0': resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/engine-javascript@4.3.0': resolution: {integrity: sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==} engines: {node: '>=20'} @@ -4071,6 +4117,9 @@ packages: '@shikijs/engine-oniguruma@2.5.0': resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/engine-oniguruma@4.3.0': resolution: {integrity: sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==} engines: {node: '>=20'} @@ -4078,17 +4127,30 @@ packages: '@shikijs/langs@2.5.0': resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/langs@4.3.0': resolution: {integrity: sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==} engines: {node: '>=20'} + '@shikijs/monaco@3.23.0': + resolution: {integrity: sha512-OCApTdAGTHMFUXSYwGztW6EnlxXsWNrpnGf+uO+AznE+khC6V1/8QjuJESIcvZUIq9iAp4ZCNYosZKSVj1Hctg==} + '@shikijs/primitive@4.3.0': resolution: {integrity: sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==} engines: {node: '>=20'} + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + '@shikijs/themes@2.5.0': resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/themes@4.3.0': resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==} engines: {node: '>=20'} @@ -4096,6 +4158,10 @@ packages: '@shikijs/transformers@2.5.0': resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + '@shikijs/transformers@4.3.1': + resolution: {integrity: sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A==} + engines: {node: '>=20'} + '@shikijs/types@2.5.0': resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} @@ -4106,6 +4172,10 @@ packages: resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==} engines: {node: '>=20'} + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -4958,6 +5028,9 @@ packages: resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} engines: {node: '>= 14.0.0'} + alien-signals@2.0.8: + resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -5806,6 +5879,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.4.7: resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} @@ -7256,6 +7332,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7311,6 +7390,11 @@ packages: peerDependencies: marked: '>=1 <16' + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -7329,8 +7413,8 @@ packages: markstream-core@1.0.3: resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==} - markstream-vue@1.0.7: - resolution: {integrity: sha512-88UVoAH34jh/BXGUVYtK8t+Tw9JETbHhRVF2DwBAIE8r8W6MO27xRADotHpz1Qn34Mas6XRW6NmqvzwwjtvciQ==} + markstream-vue@1.0.9-beta.1: + resolution: {integrity: sha512-JMv5XyX/Grpw45AuclbKRT42/dc0qYppLBLoZaJHCT/DVMjyVB7Vw5Z0i0qRKkd+LaUmELkoVNt7uMqtdS092A==} peerDependencies: '@antv/infographic': ^0.2.3 '@terrastruct/d2': '>=0.1.33' @@ -7621,6 +7705,9 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -8728,6 +8815,9 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + shiki@4.3.0: resolution: {integrity: sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==} engines: {node: '>=20'} @@ -8896,14 +8986,28 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-markdown-parser@1.1.4: - resolution: {integrity: sha512-fjkHUh7uIOH5Yp/kt26l2td+q/4sRusMFO4iB0d+GcWj40ZZuH+HxxiLxGeSD1oo8YdyapokzHOfchDIYFnNgg==} + stream-diffs@0.0.2: + resolution: {integrity: sha512-mYH+kBCmrN2ebVPNkvQIFUSPemFxUPPPk+NT4H/wEzZ9NCt5Eaa8OE05f/1MA5HofZ64LYbTOliQTU6VghHeVA==} + peerDependencies: + vue: ^3.3.0 + peerDependenciesMeta: + vue: + optional: true + + stream-markdown-parser@1.1.7: + resolution: {integrity: sha512-+0TE9Xcg6lh8xghVHlZiqU6vF5rK1Lqjb+IwgCsN+/gE4HjEQLGyhWJfz+gngUh6B7O4uaCSRvu8+ZYNOBmakw==} stream-markdown@0.0.16: resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==} peerDependencies: shiki: '>=3.23.0' + stream-monaco@0.0.49: + resolution: {integrity: sha512-ksZYJXw45NralnpgWqcptqrgDdbGZaTyYmvATvhlK8eY0tyeTxs0iuQzMTF4cpGf5OlIgkNJOfbFh4M97OozSw==} + hasBin: true + peerDependencies: + monaco-editor: '>=0.52.2 <0.56.0' + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -11898,6 +12002,30 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.59.0': optional: true + '@pierre/diffs@1.2.12(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.3.0) + '@shikijs/transformers': 4.3.1 + diff: 9.0.0 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + shiki: 4.3.0 + transitivePeerDependencies: + - '@shikijs/themes' + + '@pierre/theme@1.1.0': {} + + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.3.0)': + optionalDependencies: + '@pierre/theme': 1.1.0 + '@shikijs/themes': 4.3.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + shiki: 4.3.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -13049,12 +13177,26 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@2.5.0': dependencies: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 3.1.1 + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13066,6 +13208,11 @@ snapshots: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13075,20 +13222,40 @@ snapshots: dependencies: '@shikijs/types': 2.5.0 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/langs@4.3.0': dependencies: '@shikijs/types': 4.3.0 + '@shikijs/monaco@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/primitive@4.3.0': dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/themes@2.5.0': dependencies: '@shikijs/types': 2.5.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13098,6 +13265,11 @@ snapshots: '@shikijs/core': 2.5.0 '@shikijs/types': 2.5.0 + '@shikijs/transformers@4.3.1': + dependencies: + '@shikijs/core': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/types@2.5.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -13113,6 +13285,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sindresorhus/is@4.6.0': {} @@ -14060,6 +14237,8 @@ snapshots: '@algolia/requester-fetch': 5.52.1 '@algolia/requester-node-http': 5.52.1 + alien-signals@2.0.8: {} + alien-signals@3.2.1: {} ansi-colors@4.1.3: {} @@ -14910,6 +15089,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16554,6 +16737,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru_map@0.4.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -16618,6 +16803,8 @@ snapshots: node-emoji: 2.2.0 supports-hyperlinks: 3.2.0 + marked@14.0.0: {} + marked@16.4.2: {} marked@18.0.5: {} @@ -16626,17 +16813,19 @@ snapshots: markstream-core@1.0.3: {} - markstream-vue@1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): + markstream-vue@1.0.9-beta.1(katex@0.17.0)(mermaid@11.15.0)(stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)))(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(stream-monaco@0.0.49(monaco-editor@0.55.1))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): dependencies: '@chenglou/pretext': 0.0.8 '@floating-ui/dom': 1.8.0 markstream-core: 1.0.3 - stream-markdown-parser: 1.1.4 + stream-markdown-parser: 1.1.7 vue: 3.5.35(typescript@6.0.2) optionalDependencies: katex: 0.17.0 mermaid: 11.15.0 + stream-diffs: 0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)) stream-markdown: 0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)) + stream-monaco: 0.0.49(monaco-editor@0.55.1) vue-i18n: 11.4.5(vue@3.5.35(typescript@6.0.2)) math-intrinsics@1.1.0: {} @@ -17157,6 +17346,11 @@ snapshots: yargs-parser: 21.1.1 yargs-unparser: 2.0.0 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + mri@1.2.0: {} ms@2.1.3: {} @@ -18573,6 +18767,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + shiki@4.3.0: dependencies: '@shikijs/core': 4.3.0 @@ -18743,7 +18948,17 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-markdown-parser@1.1.4: + stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)): + dependencies: + '@pierre/diffs': 1.2.12(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + vue: 3.5.35(typescript@6.0.2) + transitivePeerDependencies: + - '@shikijs/themes' + - react + - react-dom + + stream-markdown-parser@1.1.7: dependencies: markdown-it-container: 4.0.0 markdown-it-footnote: 4.0.0 @@ -18763,6 +18978,13 @@ snapshots: - solid-js - vue + stream-monaco@0.0.49(monaco-editor@0.55.1): + dependencies: + '@shikijs/monaco': 3.23.0 + alien-signals: 2.0.8 + monaco-editor: 0.55.1 + shiki: 3.23.0 + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} From 17dfd49768f753a4f0fe97d8e7d3317dab560575 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 31 Jul 2026 00:38:11 +0800 Subject: [PATCH 5/6] feat(agent-core-v2): introduce the Workspace domain and the agent-profile registry extension point (#2366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-core-v2): insert Workspace lifecycle scope and remove mutable cwd paths - Insert LifecycleScope.Workspace between App and Session - Delete session/workspaceCommand domain (addAdditionalDir) and node-sdk RPC - Remove profile cwd mutation; cwd is fixed at creation - Make ISessionWorkspaceContext read-only; seed additionalDirs at creation - Remove TUI and vscode /add-dir commands (to return workspace-scoped) * feat(agent-core-v2): add Workspace scope with handler-owned session lifecycle - Add IWorkspaceLifecycleService (App scope): handler registry, create-or-get handlerFor with inflight join - Add workspace/workspaceContext seed and workspace/workspaceHandler (session create/resume/fork as handler child scopes) - Delete App-level ISessionLifecycleService; callers compose index -> handlerFor -> handler via sessionLookup helpers - Slim IBootstrapService; persistence addressing via handler chain (disk layout byte-identical) - kap-server routes rewire internally; /api/v1 wire unchanged, debug surface gains workspace addressing - Pin red line in domain lint: session/agent must not import workspace domains * feat(agent-core-v2): collect workspace resources into the handler scope - Add Workspace-scope catalogs for skills and agent profiles, instructions service, and a shared MCP connection manager (built at materialization, refreshed by watch/plugin events) - Session catalogs keep their APIs but read seeded snapshots and refresh via change events; ISessionMcpService removed - Session create options carry no mcpServers; MCP sources are config file (wins on name conflicts) and plugins only - Agent profile/mcp consume the seeded providers * feat(agent-core-v2): restore add-dir as a workspace-level capability - Add workspace/workspaceDirs: shared additional-dir set with addDir({path, persist}); persist=true writes .kimi-code/local.toml, local.toml watch drives cross-process refresh - ISessionWorkspaceContext becomes a live read view fed by the ISessionWorkspaceInfo seed contract and change events - Restore Session.addAdditionalDir in kimi-code-sdk 1:1, mapping to the workspace service; restore TUI/vscode /add-dir verbatim * feat(agent-core-v2): collect os-level services into the workspace scope - Move fs service, fs watch (shared subscription fan-out), process runner, and a git facade to Workspace scope; sessionFs domain removed - Add IWorkspaceToolPolicy with workspace veto wired through tool activation, execution guard, composed evaluation, and profile prompt projection; injected via ISessionToolPolicyGate seed - kap-server fs routes and fs.watch bridge remap to the workspace services; wire unchanged * refactor(agent-core-v2): clean up workspace-domain leftovers and docs - Drop dead code: v2 mergeCallerMcpServers, the transitional ISessionContext.additionalDirs field, an unreachable guard - Fix stale domain references in comments; correct test names - Give the fs-watch refresh test a realistic wait budget under load - Document the four-scope model and workspace domain in AGENTS.md, agent-core-v2 docs, and the agent-core-dev skill * test(node-sdk): wait for the initial MCP connect to settle in the parity list test v1 connects in the background after create resolves while v2 awaits it inside create, so an immediate list can catch either side still pending under CI load * refactor(agent-core-v2): extract git work-tree discovery into the git domain - add the pure findGitWorkTree probe in app/git/workTree and expose it as IGitService.findWorkTree - switch the git permission policies off the local findLocalGitWorkTreeMarker helper to the DI service - reuse findGitWorkTree for AGENTS.md project-root discovery in agent/profile/context.ts - add findWorkTree coverage to gitService.test.ts * feat(kimi-inspect): add Workspace Services view - add WorkspaceServicesView rail view with a workspace picker on top; proxies resolve workspace-scope Services on the /workspace/:id route - extend ChannelScope, ServiceTarget, and ServicePanelDef scope with 'workspace', routed via client.workspace(id).service - wire the new view into NavRail and App * refactor(agent-core-v2): extract mcpCore and workspaceMcpConfig domains - move the scope-agnostic MCP connection layer (stdio/http/sse clients, connection manager, oauth, config schema, tool naming) from agent/mcp to the new mcpCore domain - move the [mcp] config section to app/mcpConfig and OAuth credential persistence to app/mcpConfig/oauthStore - introduce the workspace/workspaceMcpConfig domain owning the effective MCP server set (mcp.json files + plugin contributions, refreshed by fs watch); workspaceMcp keeps pure connection orchestration - update the plugin domain, session MCP handle, klient/node-sdk contracts, and tests accordingly * refactor(agent-core-v2): remove the fault-injection experimental feature - delete the faultInjection domain (flag definition, IFaultInjectionService contract, FaultInjectionService implementation) - drop the requester-side take() injection point and the constructor dependency from llmRequester - remove the flag-gated test cases and the IFlagService stub they needed - regenerate the state manifest without the faultInjection state keys * feat(agent-core-v2): gate project-level MCP config behind workspace trust Add the Workspace-scope IWorkspaceTrust service: an explicit, per-workspace trust marker persisted under the home (IAtomicDocumentStore, keyed by encodeWorkDirKey(root)) so a checked-out tree cannot pre-trust itself. While a workspace is untrusted, workspaceMcpConfig skips the project-level .mcp.json and .kimi-code/mcp.json files (user-level config and plugin contributions still load); a trust flip reuses the reload path, so project servers connect on trust and disconnect on untrust. Expose the state over kap-server REST: GET /workspaces/{id}/trust, POST /workspaces/{id}/trust, POST /workspaces/{id}/untrust. * feat(kimi-inspect): replace the workspace picker with a directory browser The Workspace Services view now keeps a server-side directory browser in a left sidebar (over IHostFolderBrowser) instead of a of registered + * workspaces. Directories are listed through the App-scope + * `IHostFolderBrowser` (home + recent roots + one-level directory listing); + * entries that are registered workspaces carry a `workspace` badge plus their + * trust state (`trusted` / `untrusted`, read through the Workspace-scope + * `IWorkspaceTrust`; servers predating that service simply show no trust + * badge). "Select" registers the current folder on demand — + * `IWorkspaceService.createOrTouch` is idempotent on root — before handing + * the workspace to the parent. + */ + +import { + IHostFolderBrowser, + type FsBrowseEntry, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { + IWorkspaceService, + type Workspace, +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import { IWorkspaceTrust } from '@moonshot-ai/agent-core-v2/workspace/workspaceTrust/workspaceTrust'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import type { InspectClient } from '../channel'; +import { ErrorLine } from '../ui'; + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/').replace(/\/+$/, ''); +} + +function baseName(path: string): string { + const normalized = normalizePath(path); + return normalized.split('/').pop() ?? normalized; +} + +export function WorkspaceDirBrowser(props: { + klient: InspectClient; + workspaces: readonly Workspace[] | undefined; + onSelect: (workspace: Workspace) => void; +}) { + const { klient, workspaces, onSelect } = props; + const queryClient = useQueryClient(); + /** Current browsed directory; null = the server default ($HOME). */ + const [path, setPath] = useState(null); + + const home = useQuery({ + queryKey: ['fs-home', klient.baseUrl], + queryFn: () => klient.core(IHostFolderBrowser).home(), + }); + const browse = useQuery({ + queryKey: ['fs-browse', klient.baseUrl, path], + queryFn: () => + path === null + ? klient.core(IHostFolderBrowser).browse() + : klient.core(IHostFolderBrowser).browse(path), + }); + + const select = useMutation({ + mutationFn: (root: string) => klient.core(IWorkspaceService).createOrTouch(root), + onSuccess: async (workspace) => { + await queryClient.invalidateQueries({ queryKey: ['workspaces', klient.baseUrl] }); + onSelect(workspace); + }, + }); + + const workspaceList = workspaces ?? []; + /** normalized root → trusted; undefined when the server predates `workspaceTrust`. */ + const trustByRoot = useQuery({ + queryKey: ['workspace-trust', klient.baseUrl, workspaceList.map((ws) => ws.id).join(',')], + enabled: workspaceList.length > 0, + retry: false, + queryFn: async () => { + const entries = await Promise.all( + workspaceList.map(async (ws) => { + try { + const trusted = await klient.workspace(ws.id).service(IWorkspaceTrust).get(); + return [normalizePath(ws.root), trusted] as const; + } catch { + return [normalizePath(ws.root), undefined] as const; + } + }), + ); + return new Map(entries); + }, + }); + + const workspaceRoots = new Set(workspaceList.map((ws) => normalizePath(ws.root))); + const current = browse.data ?? null; + const recents = home.data?.recent_roots ?? []; + const isCurrentWorkspace = current !== null && workspaceRoots.has(normalizePath(current.path)); + + const renderEntry = (entry: FsBrowseEntry) => { + const isWorkspace = workspaceRoots.has(normalizePath(entry.path)); + const trusted = trustByRoot.data?.get(normalizePath(entry.path)); + return ( + + ); + }; + + return ( +
+
+
+ + + +
+ + {current?.path ?? '…'} + +
+ {recents.length > 0 ? ( +
+ + recent + + {recents.map((root) => ( + + ))} +
+ ) : null} +
+ {browse.isError ? ( +
+ +
+ ) : current === null ? ( +
loading…
+ ) : current.entries.length === 0 ? ( +
no subdirectories
+ ) : ( + current.entries.map(renderEntry) + )} +
+ {select.isError ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx b/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx new file mode 100644 index 000000000..4b094c924 --- /dev/null +++ b/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx @@ -0,0 +1,93 @@ +/** + * Workspace Services view — the workspace-scope Service reflection as a + * standalone rail view. Same Postman-style three-pane layout + * (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the + * workspace picker: a server-side directory browser (`WorkspaceDirBrowser`, + * over the App-scope `IHostFolderBrowser`) that marks already-registered + * workspaces and registers a picked folder on demand. The proxies resolve on + * the `/workspace/:id` route, so a workspace must be selected before any + * Service is callable. Picking one materializes its handler on demand + * server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no + * manual join needed. + */ + +import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ErrorLine } from '../ui'; +import { ScopePanelsScrollspy } from './ServicePanels'; +import { WorkspaceDirBrowser } from './WorkspaceDirBrowser'; + +export function WorkspaceServicesView() { + const { klient, baseUrl } = useConnection(); + const [workspaceId, setWorkspaceId] = useState(null); + + const workspaces = useQuery({ + queryKey: ['workspaces', klient.baseUrl], + queryFn: () => klient.core(IWorkspaceService).list(), + }); + + // Switching servers invalidates the selection: workspaces belong to the + // server they were listed from. + useEffect(() => { + setWorkspaceId(null); + }, [baseUrl]); + + const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId); + + const proxyFor = useCallback( + (name: string): AnyService | null => + workspaceId === null + ? null + : (serviceByName(klient, name, { scope: 'workspace', workspaceId }) ?? null), + [klient, workspaceId], + ); + + return ( +
+ +
+ {workspaceId === null ? ( +
+ select a workspace to inspect its Services +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index 92abed8a4..109590517 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -65,7 +65,7 @@ export interface ServicePanelDef { readonly id: string; readonly label: string; /** Wire scope the Service is called on (`app` maps to the `core` route). */ - readonly scope: 'app' | 'session' | 'agent'; + readonly scope: 'app' | 'workspace' | 'session' | 'agent'; readonly fetch?: (svc: AnyService) => Promise; readonly actions?: readonly PanelAction[]; } diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a9ef407be..6707fd4ca 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -356,6 +356,11 @@ export class AcpServer implements Agent { // record verbatim. The `@ts-expect-error` documents this contract; // if the SDK ever switches from spread-passthrough to explicit field // copy, this line breaks and we revisit the boundary. + // NOTE (workspace-domain consolidation): the passthrough reaches the + // v1 kernel only. The v2 engine has NO caller `mcpServers` channel on + // session create/resume (its MCP manager is per-workspace-handler, fed + // by config files and plugins only) — how ACP-supplied servers should + // reach the v2 engine is left to a future ACP-specific design. const mcpServers = acpMcpServersToConfigs(params.mcpServers); if (!this.conn) { // Defensive: every code path that constructs `AcpServer` (the diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 8646b066a..f8c82bbb7 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -2,6 +2,10 @@ > New agent engine built on the DI Scope architecture — work-in-progress port of `packages/agent-core`. Design: `plan/PLAN.md`. Porting status: `GAP_ANALYSIS.md`. +## Scopes + +Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `workspaceHandler` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the App-level `ISessionLifecycleService` / `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. + ## Examples > The runnable examples have moved to the standalone `kimi-code-mini-bench` package at `../kimi-code-mini-bench`. They are wired to `agent-core-v2` through a pnpm `link:` dependency and run as a separate Vitest project. diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index d89be92ab..8092e735f 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -14,12 +14,12 @@ # defaultPermissionMode src/agent/permissionMode/configSection.ts # defaultPlanMode src/agent/plan/configSection.ts # experimental src/app/flag/flag.ts -# extraAgentDirs src/app/agentFileCatalog/configSection.ts +# extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts # extraSkillDirs src/app/skillCatalog/configSection.ts # hooks src/agent/externalHooks/configSection.ts # image src/agent/media/configSection.ts # loopControl src/agent/loop/configSection.ts -# mcp src/agent/mcp/configSection.ts +# mcp src/app/mcpConfig/configSection.ts # mergeAllAvailableSkills src/app/skillCatalog/configSection.ts # modelCatalog src/app/kosongConfig/configSection.ts # models src/app/kosongConfig/configSection.ts @@ -106,7 +106,7 @@ default_plan_mode = false # ########################################################################## # extraAgentDirs (config.toml: extra_agent_dirs) -# owner: src/app/agentFileCatalog/configSection.ts +# owner: src/workspace/workspaceAgentProfileLoader/configSection.ts # scope: core # ########################################################################## @@ -167,7 +167,7 @@ extra_skill_dirs = [] # ########################################################################## # mcp -# owner: src/agent/mcp/configSection.ts +# owner: src/app/mcpConfig/configSection.ts # scope: core # hooks: stripEnv # env: diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md index e892be896..15be4b847 100644 --- a/packages/agent-core-v2/docs/di.md +++ b/packages/agent-core-v2/docs/di.md @@ -139,15 +139,16 @@ const meta = accessor.get(ISessionMetadata); // 类型是 ISessionMetadata > 你要做的:每个会话一份、或每个 agent 一份。参考 [`sessionMetadata`](../src/session/sessionMetadata/sessionMetadata.ts)、[`turn`](../src/turn/turn.ts)。 -这一步引入:**`LifecycleScope` 三层生命周期** 与 **父子 scope 的可见性**。 +这一步引入:**`LifecycleScope` 四层生命周期** 与 **父子 scope 的可见性**。 -### 3.1 三层,按寿命从长到短 +### 3.1 四层,按寿命从长到短 ```ts export enum LifecycleScope { - App = 0, // 进程级,全局一份 - Session = 1, // 一次会话 - Agent = 2, // 一个 agent + App = 0, // 进程级,全局一份 + Workspace = 1, // 一个工作区 handler(与 Session 一对多) + Session = 2, // 一次会话 + Agent = 3, // 一个 agent } ``` @@ -171,15 +172,16 @@ Scope 是一棵树,`kind` 必须沿父子方向**严格递增**: ``` App (0) - └── Session (1) - └── Agent (2) + └── Workspace (1) + └── Session (2) + └── Agent (3) ``` 解析服务时,容器先看自己这一层,没有就**递归问父 scope**。所以一条铁律: > **短寿命的服务可以注入长寿命的服务,反过来不行。** -- ✅ Agent 服务注入 Session / App 服务(往上找,找得到)。 +- ✅ Agent 服务注入 Session / Workspace / App 服务(往上找,找得到)。 - ❌ App 服务注入 Session 服务(App 创建时 Session 还不存在,且父不会往下找)。 这条规则由树的结构强制保证,不靠纪律维持。 @@ -350,7 +352,7 @@ A 创建中要 B,B 创建中又要 A——容器会抛 `CyclicDependencyError` ### 9.2 为什么不允许 -- scope 分层让正常依赖天然是 DAG(Agent → Session → App 向上找),一个环几乎总是设计味道。 +- scope 分层让正常依赖天然是 DAG(Agent → Session → Workspace → App 向上找),一个环几乎总是设计味道。 - 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。 所以 v2 的立场是:**依赖图必须是无环的。** diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index ccb2a71ea..766946ee7 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -65,7 +65,7 @@ - W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写** (`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。 - W4 fork 直接在 appendLogStore 层改写 wire log,绕过全部写模型 - (`sessionLifecycleService.ts:303-337`)。 + (`workspaceHandlerService.ts` 的 `fork` / `copyAgentWire`)。 - W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`), 但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。 @@ -98,7 +98,7 @@ onChange 处理器若 append 会无检测地重入。 - L3 restore 正确性依赖三重隐式契约:DI 构造顺序 + hook 注册顺序 + "resumer 先于 hooks";`doResume` 需手动预热 contextMemory - (`sessionLifecycleService.ts:158-162`)。 + (现 `workspaceHandlerService.ts` 的 `doResume` / `materializeSession`)。 - L4 相位规则(restoring / postRestoring / live)在 append/signal/push/hook 四条通道上各不相同,没有一处集中定义。 @@ -189,7 +189,7 @@ 逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。 - fork 保持现实现(复制 main 的 wire log);接口上表达为 `stream.forkInto(target)`,实现仍走 appendLogStore(W4 的接口层收口: - 唯一入口,不再散落在 sessionLifecycle 里手写)。 + 唯一入口,不再散落在 workspaceHandler 里手写)。 - App scope 一条逻辑流(config/model catalog/session 生命周期),取代 `IEventService`(V4)——App 流本就无持久化,纯接口替换。 - **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用 diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md index e78aa848e..54bd30117 100644 --- a/packages/agent-core-v2/docs/service-design.md +++ b/packages/agent-core-v2/docs/service-design.md @@ -37,11 +37,12 @@ Every principle below derives from two root questions: **First principle: Scope = the identity + lifetime of the owned state.** -`App` / `Session` / `Agent` are three tiers of identity + lifetime: +`App` / `Workspace` / `Session` / `Agent` are four tiers of identity + lifetime: | Scope | State identity (keyed by) | Lifetime | |---|---|---| | `App` | none (single global instance) | the process | +| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | | `Session` | `sessionId` | one session | | `Agent` | `agentId` | one agent | @@ -55,6 +56,7 @@ Every principle below derives from two root questions: **Q2. What is the identity of that state?** - one global instance → **`App`** +- one per workspace (shared by every session of that workspace) → **`Workspace`** - one per session → **`Session`** - one per agent → **`Agent`** - a mix (a global registry *and* per-instance state) → **do not put it in one Service; @@ -108,7 +110,7 @@ job well. | Tier | Role | Naming tends to | |---|---|---| | `App` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | -| `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` | +| `Workspace` / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | This pattern recurs throughout the codebase and confirms the rule: diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index f1027e72b..dfe4bd4f1 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -15,44 +15,32 @@ // expand structurally; classes render as their public instance shape. The // defining source file heads each group. // -// External ambient types referenced but not expanded (from node_modules): Readable, Writable -// // snapshot() returns JSON-safe deep copies of these values: Maps become plain // objects (or [key, value] entry arrays when a key is not string/number), Sets // become arrays, bigints become strings, functions are dropped, circular // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 28 keys · Agent: 67 keys) +// Index (Session: 18 keys · Agent: 67 keys) // Session -// cron.inFlight src/session/cron/sessionCronServiceImpl.ts -// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts -// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts -// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts -// cron.started src/session/cron/sessionCronServiceImpl.ts -// cron.tasks src/session/cron/sessionCronServiceImpl.ts -// interaction.nextId src/session/interaction/interactionService.ts -// interaction.pending src/session/interaction/interactionService.ts -// interaction.recentlyResolved src/session/interaction/interactionService.ts -// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts -// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts -// sessionAgentProfileCatalog.contributions src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts -// sessionAgentProfileCatalog.merged src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts -// sessionFs.realRootsCache src/session/sessionFs/fsService.ts -// sessionFs.rgResolution src/session/sessionFs/fsService.ts -// sessionFsWatch.gitignoreLoaded src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.pending src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.rawCount src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.truncated src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.watched src/session/sessionFs/fsWatchService.ts -// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts -// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts -// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts -// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts -// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts -// workspaceCommand.pendingMainInjections src/session/workspaceCommand/workspaceCommandService.ts -// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts -// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts +// cron.inFlight src/session/cron/sessionCronServiceImpl.ts +// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts +// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts +// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts +// cron.started src/session/cron/sessionCronServiceImpl.ts +// cron.tasks src/session/cron/sessionCronServiceImpl.ts +// interaction.nextId src/session/interaction/interactionService.ts +// interaction.pending src/session/interaction/interactionService.ts +// interaction.recentlyResolved src/session/interaction/interactionService.ts +// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts +// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts +// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts +// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts +// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts +// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts +// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts +// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts +// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts // Agent // activityView.background src/agent/activityView/activityViewService.ts // activityView.current src/agent/activityView/activityViewService.ts @@ -176,156 +164,6 @@ export interface SessionStateSnapshot { background: number; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }>; - // src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts - 'sessionAgentProfileCatalog.contributions': Map string; - readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly cwd: string; - readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly _serviceBrand: undefined; - exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly cwd?: string; - readonly env?: Record; - }) => Promise Promise; - kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise; - dispose: () => void | Promise; - }>; - }; - readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ { - error: (message: string, payload?: unknown) => void; - warn: (message: string, payload?: unknown) => void; - info: (message: string, payload?: unknown) => void; - debug: (message: string, payload?: unknown) => void; - child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ { - [key: string]: unknown; - }) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown; - }; - }) => Promise; - readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; - }; - }[]; - readonly skipped?: readonly /* SkippedAgentFile — packages/agent-core-v2/src/app/agentFileCatalog/types.ts */ { - readonly path: string; - readonly reason: string; - }[]; - readonly scannedRoots?: readonly string[]; - }; - readonly priority: number; - }>; - 'sessionAgentProfileCatalog.merged': Map string; - readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly cwd: string; - readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly _serviceBrand: undefined; - exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly cwd?: string; - readonly env?: Record; - }) => Promise Promise; - kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise; - dispose: () => void | Promise; - }>; - }; - readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ { - error: (message: string, payload?: unknown) => void; - warn: (message: string, payload?: unknown) => void; - info: (message: string, payload?: unknown) => void; - debug: (message: string, payload?: unknown) => void; - child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ { - [key: string]: unknown; - }) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown; - }; - }) => Promise; - readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; - }; - }>; - // src/session/sessionFs/fsService.ts - 'sessionFs.realRootsCache': { - readonly key: string; - readonly roots: readonly string[]; - } | undefined; - 'sessionFs.rgResolution': /* RgResolution — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ { - readonly path: string; - readonly source: /* RgResolutionSource — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ 'system-path' | 'share-bin-cached'; - } | null | undefined; - // src/session/sessionFs/fsWatchService.ts - 'sessionFsWatch.gitignoreLoaded': boolean; - 'sessionFsWatch.pending': /* FsChangeEntry — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ { - path: string; - change: /* FsChangeAction — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'created' | 'modified' | 'deleted'; - kind: /* FsChangeKind — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'file' | 'directory' | 'symlink'; - size_delta?: number; - etag?: string; - }[]; - 'sessionFsWatch.rawCount': number; - 'sessionFsWatch.truncated': boolean; - 'sessionFsWatch.watched': Set; // src/session/sessionLog/sessionLogService.ts 'sessionLog.rootLevel': /* LogLevelState — packages/agent-core-v2/src/_base/log/logService.ts */ { level: /* LogLevel — packages/agent-core-v2/src/_base/log/log.ts */ 'info' | 'off' | 'error' | 'warn' | 'debug'; @@ -588,112 +426,6 @@ export interface SessionStateSnapshot { 'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ { readonly disabledTools: readonly string[]; }; - // src/session/workspaceCommand/workspaceCommandService.ts - 'workspaceCommand.pendingMainInjections': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/kosong/contract/message.ts */ { - readonly role: /* Role — packages/agent-core-v2/src/kosong/contract/message.ts */ 'user' | 'system' | 'assistant' | 'tool'; - readonly name?: string; - readonly content: (/* ContentPart — packages/agent-core-v2/src/kosong/contract/message.ts */ /* TextPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'text'; - text: string; - } | /* ThinkPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'think'; - think: string; - encrypted?: string; - } | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'image_url'; - imageUrl: { - url: string; - id?: string; - }; - } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'audio_url'; - audioUrl: { - url: string; - id?: string; - }; - } | /* VideoURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'video_url'; - videoUrl: { - url: string; - id?: string; - }; - })[]; - readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'function'; - id: string; - name: string; - arguments: string | null; - extras?: Record; - _streamIndex?: string | number; - }[]; - readonly toolCallId?: string; - readonly partial?: boolean; - readonly tools?: readonly /* Tool — packages/agent-core-v2/src/kosong/contract/tool.ts */ { - name: string; - description: string; - parameters: Record; - deferred?: true; - }[]; - } & { - readonly id?: string; - readonly providerMessageId?: string; - readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'user'; - } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'skill_activation'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'plugin_command'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; - } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'injection'; - readonly variant: string; - readonly ownerPromptId?: string; - } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'shell_command'; - readonly phase: 'input' | 'output'; - readonly isError?: boolean; - } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'compaction_summary'; - } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'system_trigger'; - readonly name: string; - } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'task'; - readonly taskId: string; - readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; - readonly notificationId: string; - } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; - } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_missed'; - readonly count: number; - } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'hook_result'; - readonly event: string; - readonly blocked?: boolean; - } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'retry'; - readonly trigger?: string; - }; - readonly isError?: boolean; - readonly note?: string; - })[]; // src/session/workspaceContext/workspaceContextService.ts 'workspaceContext.additionalDirs': string[]; 'workspaceContext.workDir': string; @@ -768,7 +500,7 @@ export interface AgentStateSnapshot { readonly trigger?: string; }; readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'assistant' | 'tool_call' | 'thinking'; + readonly stream?: 'tool_call' | 'assistant' | 'thinking'; readonly step: number; readonly ending: boolean; readonly endingReason?: 'error' | 'aborted' | 'max_steps'; @@ -813,7 +545,7 @@ export interface AgentStateSnapshot { 'activityView.lifecycle': /* ActivityViewLifecycle — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'ready' | 'disposed'; 'activityView.turn': /* MutableTurn — packages/agent-core-v2/src/agent/activityView/activityViewService.ts */ { phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - stream: 'assistant' | 'tool_call' | 'thinking' | undefined; + stream: 'tool_call' | 'assistant' | 'thinking' | undefined; step: number; ending: boolean; endingReason: 'error' | 'aborted' | 'max_steps' | undefined; @@ -948,7 +680,7 @@ export interface AgentStateSnapshot { readonly trigger?: string; }; readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'assistant' | 'tool_call' | 'thinking'; + readonly stream?: 'tool_call' | 'assistant' | 'thinking'; readonly step: number; readonly ending: boolean; readonly endingReason?: 'error' | 'aborted' | 'max_steps'; @@ -1008,7 +740,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': MapagentTask', 'llmRequester>session', - 'loop>mcp', // `registerMediaTools` (media, L4) imports the `ReadMediaFileTool` // implementation from the `tools` domain (L7) to register it for media // capability agents. @@ -505,7 +628,6 @@ const ALLOWED_EXCEPTIONS = new Set([ // projection side references the context message type by structure only. 'record>contextMemory', 'plugin>externalHooks', - 'plugin>mcp', 'profile>session', 'replayBuilder>agentTask', 'replayBuilder>rpc', @@ -520,7 +642,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'filestore>persistence/backends', 'process>os/backends', 'terminal>os/backends', - 'sessionFs>os/backends', 'blobStore>persistence/backends', // `sessionIndex` (L2) reads the `persistence_minidb_readmodel` experimental // flag (L3) to switch session listings between the legacy N+1 disk read and @@ -639,6 +760,22 @@ export function checkSource(source, absFile) { continue; } + // Rule 2b: scope direction — Session/Agent tiers never import the + // Workspace tier (`src/workspace/**` or `src/app/workspaceLifecycle/**`). + const sourceTier = scopeTierOf(absFile); + if (SESSION_AGENT_TIERS.has(sourceTier)) { + const targetTier = scopeTierOf(targetAbs); + const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); + if (targetTier === 'workspace' || targetRel.startsWith(WORKSPACE_LIFECYCLE_PREFIX)) { + violations.push({ + file: absFile, + line, + message: `scope violation: '${sourceTier}' tier must not import Workspace-tier code ('${targetRel}' via '${specifier}') — workspace capabilities reach sessions only through session-domain contracts + scope seeds`, + }); + continue; + } + } + // Rule 3b: kosong-internal layering. Runs even for same-domain imports // because the provider/bases sub-boundary also bans same-domain targets // (registries and contrib modules live beside the bases). diff --git a/packages/agent-core-v2/src/_base/contribution/registry.ts b/packages/agent-core-v2/src/_base/contribution/registry.ts new file mode 100644 index 000000000..03238616d --- /dev/null +++ b/packages/agent-core-v2/src/_base/contribution/registry.ts @@ -0,0 +1,71 @@ +/** + * `_base/contribution` domain (L1) — generic source-keyed contribution + * registry. + * + * The storage half of the Contribution / Registry / Catalog extension-point + * pattern: a *contribution* is a plain data structure offered by an outer + * contributor (a loader, a plugin, a code module); the *registry* stores at + * most one contribution per `sourceId` — re-registering the same `sourceId` + * replaces the previous entry, which is the only dedup this layer performs. + * Content-level dedup (e.g. by item name), ordering, and merge rules are the + * Catalog's projection job, never the registry's. `register` returns a handle + * whose `dispose` unregisters — but only the entry it registered, so a stale + * handle can never evict a newer re-registration. Every mutation fires + * `onDidChange` with the affected `sourceId` so catalogs can re-project. + */ + +import { Disposable, type IDisposable } from '../di/lifecycle'; +import { Emitter, type Event } from '../event'; + +export interface ContributionRegistration { + readonly sourceId: string; + readonly priority: number; + readonly contribution: T; +} + +export interface RegisterContributionOptions { + readonly priority?: number; +} + +export class ContributionRegistry extends Disposable { + private readonly registrations = new Map>(); + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + register( + sourceId: string, + contribution: T, + options?: RegisterContributionOptions, + ): IDisposable { + const registration: ContributionRegistration = { + sourceId, + priority: options?.priority ?? 0, + contribution, + }; + this.registrations.set(sourceId, registration); + this.onDidChangeEmitter.fire(sourceId); + let active = true; + return { + dispose: () => { + if (!active) return; + active = false; + if (this.registrations.get(sourceId) !== registration) return; + this.registrations.delete(sourceId); + this.onDidChangeEmitter.fire(sourceId); + }, + }; + } + + unregister(sourceId: string): void { + if (!this.registrations.delete(sourceId)) return; + this.onDidChangeEmitter.fire(sourceId); + } + + entries(): readonly ContributionRegistration[] { + return [...this.registrations.values()]; + } + + get(sourceId: string): ContributionRegistration | undefined { + return this.registrations.get(sourceId); + } +} diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 2aac50e69..dc720c9ba 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -13,8 +13,9 @@ import { ServiceCollection } from './serviceCollection'; export enum LifecycleScope { App = 0, - Session = 1, - Agent = 2, + Workspace = 1, + Session = 2, + Agent = 3, } export enum ScopeActivation { @@ -76,6 +77,7 @@ export interface IScopeHandle { } export type IAppScopeHandle = IScopeHandle; +export type IWorkspaceScopeHandle = IScopeHandle; export type ISessionScopeHandle = IScopeHandle; export type IAgentScopeHandle = IScopeHandle; diff --git a/packages/agent-core-v2/src/_base/text/frontmatter.ts b/packages/agent-core-v2/src/_base/text/frontmatter.ts new file mode 100644 index 000000000..a28d6a423 --- /dev/null +++ b/packages/agent-core-v2/src/_base/text/frontmatter.ts @@ -0,0 +1,54 @@ +/** + * `_base` text helpers — Markdown frontmatter parsing. + * + * Splits a Markdown document into its YAML frontmatter block and body. Pure + * text processing with no IO and no domain knowledge: skill definitions, + * agent-definition files, and plugin command files all share this one parser + * instead of depending on each other's domains for it. A document without a + * leading `---` fence parses as all body with `data: null`; an unterminated + * fence is a `FrontmatterError`. + */ + +import { load as loadYaml } from 'js-yaml'; + +export class FrontmatterError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'FrontmatterError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { value: cause, configurable: true }); + } + } +} + +export interface ParsedFrontmatter { + readonly data: unknown; + readonly body: string; +} + +const FENCE = '---'; + +export function parseFrontmatter(text: string): ParsedFrontmatter { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== FENCE) { + return { data: null, body: text }; + } + + const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); + if (close === -1) { + throw new FrontmatterError('Missing closing frontmatter fence'); + } + + const yamlText = lines.slice(1, close).join('\n').trim(); + const body = lines.slice(close + 1).join('\n'); + if (yamlText === '') { + return { data: {}, body }; + } + + try { + return { data: loadYaml(yamlText) ?? {}, body }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new FrontmatterError(message, error); + } +} diff --git a/packages/agent-core-v2/src/_base/utils/fileMeta.ts b/packages/agent-core-v2/src/_base/utils/fileMeta.ts index f3419c5b3..19757d895 100644 --- a/packages/agent-core-v2/src/_base/utils/fileMeta.ts +++ b/packages/agent-core-v2/src/_base/utils/fileMeta.ts @@ -2,7 +2,7 @@ * File content metadata helpers — binary detection, line counting, etag, and * extension-based mime / language guessing. * - * Shared by the fs edge domains (`sessionFs`) and the kap-server fs routes so + * Shared by the fs edge domains (`workspaceFs`) and the kap-server fs routes so * every read-style surface classifies and labels file content the same way. * Pure functions over bytes, text, and stat-like shapes; no io happens here. * Binary detection samples the leading `FS_BINARY_SAMPLE_BYTES` of a file and diff --git a/packages/agent-core-v2/src/_base/utils/isoDateTime.ts b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts index f5346c4d2..6b1d75a6a 100644 --- a/packages/agent-core-v2/src/_base/utils/isoDateTime.ts +++ b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts @@ -6,7 +6,7 @@ const ISO_8601_REGEX = /** * Wire-schema primitive for ISO 8601 datetime strings: validates the shape and * normalizes to `Date#toISOString()` output. Shared by the edge DTO schemas - * (`sessionFs`, `file`, `terminal`, `auth`, …) that expose timestamps. + * (`workspaceFs`, `file`, `terminal`, `auth`, …) that expose timestamps. */ export const isoDateTimeSchema = z .string() diff --git a/packages/agent-core-v2/src/_base/utils/paths.ts b/packages/agent-core-v2/src/_base/utils/paths.ts new file mode 100644 index 000000000..0bb09858e --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/paths.ts @@ -0,0 +1,34 @@ +/** + * Path-filter helpers — pure string predicates, no IO. + */ + +function normalizeSlashes(p: string): string { + return p.replaceAll('\\', '/'); +} + +/** + * A chokidar `ignored` predicate that prunes a recursive watch on `root` + * down to the given candidate subtrees (files or directories beneath it). + * The root itself, every ancestor of a candidate (so traversal can reach + * it), and every path inside a candidate pass the filter; everything else + * is ignored, so large sibling trees (dependency folders, VCS metadata) + * are never walked. This is the reliable way to watch candidates that may + * not exist yet: watching a missing path directly silently never fires + * when its parent directory is missing too. + */ +export function subtreeWatchFilter( + root: string, + candidates: readonly string[], +): (path: string) => boolean { + const normRoot = normalizeSlashes(root); + const normCandidates = candidates.map(normalizeSlashes); + return (p: string): boolean => { + const norm = normalizeSlashes(p); + if (norm === normRoot) return false; + for (const candidate of normCandidates) { + if (norm === candidate || norm.startsWith(`${candidate}/`)) return false; + if (candidate.startsWith(`${norm}/`)) return false; + } + return true; + }; +} diff --git a/packages/agent-core-v2/src/_base/utils/timer.ts b/packages/agent-core-v2/src/_base/utils/timer.ts index 2d6f00d55..38094f683 100644 --- a/packages/agent-core-v2/src/_base/utils/timer.ts +++ b/packages/agent-core-v2/src/_base/utils/timer.ts @@ -49,3 +49,38 @@ export class IntervalTimer implements IDisposable { this.cancel(); } } + +/** + * One-shot debounce primitive — a disposable `setTimeout` wrapper. + * + * `TimeoutTimer` owns a single `setTimeout` handle: every `cancelAndSet` + * cancels the pending run and re-arms, so a burst of triggers collapses into + * one deferred run (watch-event debounce). `dispose` guarantees the handle is + * cleared. Mirrors VS Code's `TimeoutTimer`. + */ +export class TimeoutTimer implements IDisposable { + private handle: ReturnType | undefined; + + cancel(): void { + if (this.handle !== undefined) { + clearTimeout(this.handle); + this.handle = undefined; + } + } + + cancelAndSet(runner: () => void, timeoutMs: number): void { + this.cancel(); + const handle = setTimeout(() => { + this.handle = undefined; + runner(); + }, timeoutMs); + if (typeof handle === 'object' && handle !== null && 'unref' in handle) { + (handle as { unref: () => void }).unref(); + } + this.handle = handle; + } + + dispose(): void { + this.cancel(); + } +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts index c7a6792d5..0e47b0972 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -2,9 +2,9 @@ import type { Tool as KosongTool } from '#/kosong/contract/tool'; import { createDecorator } from "#/_base/di/instantiation"; import { type IDisposable } from "#/_base/di/lifecycle"; -import type { McpServerEntry } from './connection-manager'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; -import type { MCPClient, MCPToolDefinition } from './types'; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; export interface McpResolvedServer { readonly client: MCPClient; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts index 4ea63a17c..226e2aa3b 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import type { MCPToolDefinition } from './types'; +import type { MCPToolDefinition } from '#/mcpCore/types'; export interface McpToolCollision { readonly qualified: string; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 532367438..d7f933d81 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -1,8 +1,10 @@ /** * `mcp` domain (L5) — `IAgentMcpService` implementation. * - * Mirrors the session-level MCP connection manager's server set into the - * agent's tool registry: registers qualified tools for connected servers, + * Mirrors the workspace-level shared MCP connection manager's server set + * into the agent's tool registry (the manager arrives through the seeded + * `ISessionMcpHandle` — one manager per workspace handler, shared by every + * session and agent): registers qualified tools for connected servers, * keeps them registered across reconnects, swaps in the OAuth tool for * `needs-auth` servers, journals tool discoveries on the wire (queued until * restore finishes), and publishes `mcp.server.status` / `tool.list.updated` @@ -32,11 +34,11 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import type { McpServerEntry } from './connection-manager'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from './mcp'; -import { qualifyMcpToolName } from './tool-naming'; -import type { MCPClient, MCPToolDefinition } from './types'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import { IWireService } from '#/wire/wire'; import { McpDiscoveryModel, @@ -97,7 +99,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { private readonly pendingDiscoveries: Array<() => void> = []; constructor( - @ISessionMcpService private readonly sessionMcp: ISessionMcpService, + @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, @ISessionContext private readonly sessionContext: ISessionContext, @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IEventBus private readonly eventBus: IEventBus, @@ -136,32 +138,32 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { } get oauthService() { - return this.sessionMcp.connectionManager().oauthService; + return this.mcpHandle.connectionManager.oauthService; } waitForInitialLoad(signal?: AbortSignal): Promise { - return this.sessionMcp.connectionManager().waitForInitialLoad(signal); + return this.mcpHandle.connectionManager.waitForInitialLoad(signal); } initialLoadDurationMs(): number { - return this.sessionMcp.connectionManager().initialLoadDurationMs(); + return this.mcpHandle.connectionManager.initialLoadDurationMs(); } list() { - return this.sessionMcp.connectionManager().list(); + return this.mcpHandle.connectionManager.list(); } resolved(name: string) { - return this.sessionMcp.connectionManager().resolved(name); + return this.mcpHandle.connectionManager.resolved(name); } getRemoteServerUrl(name: string) { - return this.sessionMcp.connectionManager().getRemoteServerUrl(name); + return this.mcpHandle.connectionManager.getRemoteServerUrl(name); } async reconnect(name: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); - await this.sessionMcp.connectionManager().reconnect(name); + await this.mcpHandle.connectionManager.reconnect(name); signal?.throwIfAborted(); } @@ -180,13 +182,13 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { ): Promise { const healed = this.resolved(serverName)?.client; if (healed !== undefined && healed !== staleClient) return healed; - await this.sessionMcp.connectionManager().reconnectAndJoin(serverName); + await this.mcpHandle.connectionManager.reconnectAndJoin(serverName); const current = this.resolved(serverName)?.client; return current !== undefined && current !== staleClient ? current : undefined; } onStatusChange(listener: Parameters[0]) { - const unsubscribe = this.sessionMcp.connectionManager().onStatusChange(listener); + const unsubscribe = this.mcpHandle.connectionManager.onStatusChange(listener); return { dispose: unsubscribe, }; diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/store.ts b/packages/agent-core-v2/src/agent/mcp/oauth/store.ts deleted file mode 100644 index 98af7f2cd..000000000 --- a/packages/agent-core-v2/src/agent/mcp/oauth/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * `mcp` domain (L5) — MCP OAuth credential store. - * - * Persists OAuth tokens, registered DCR client info, and discovery state for - * MCP HTTP servers through the `storage` access-pattern store - * (`IAtomicDocumentStore`) under the `credentials/mcp` scope - * (`/credentials/mcp/-*.json`). One logical record per - * `(serverName, serverUrl)` identity, addressed by {@link mcpOAuthStoreKey}. - * - * Read semantics: missing or corrupt JSON resolves to `undefined` (never - * throws). The provider treats `undefined` as "not stored". - */ - -import { createHash } from 'node:crypto'; - -import { basename } from 'pathe'; - -import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; - -const CREDENTIALS_SCOPE = 'credentials/mcp'; - -export function sanitizeStoreKey(name: string): string { - const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); - if (safe.length === 0 || safe.startsWith('.')) { - throw new Error(`Invalid MCP OAuth store key: "${name}"`); - } - return safe; -} - -export function canonicalMcpOAuthResource(serverUrl: string | URL): string { - const url = new URL(serverUrl); - url.hash = ''; - return url.toString(); -} - -export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string { - const safeName = sanitizeStoreKey(serverName); - const resource = canonicalMcpOAuthResource(serverUrl); - const digest = createHash('sha256') - .update(serverName) - .update('\0') - .update(resource) - .digest('hex') - .slice(0, 24); - return `${safeName}-${digest}`; -} - -export interface McpOAuthStore { - read(key: string): Promise; - write(key: string, data: unknown): Promise; - remove(key: string): Promise; -} - -export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore { - return { - async read(key: string): Promise { - try { - return await docs.get(CREDENTIALS_SCOPE, key); - } catch { - return undefined; - } - }, - write(key, data) { - return docs.set(CREDENTIALS_SCOPE, key, data); - }, - remove(key) { - return docs.delete(CREDENTIALS_SCOPE, key); - }, - }; -} diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 137930e54..79cd3a3df 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -36,7 +36,7 @@ import { isModelAcceptedImageMime, } from '#/agent/media/image-format-policy'; import { persistOriginalImage } from '#/agent/media/image-originals'; -import type { MCPContentBlock, MCPToolResult } from './types'; +import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; export interface McpOutputOptions { readonly originalsDir?: string; diff --git a/packages/agent-core-v2/src/agent/mcp/session-config.ts b/packages/agent-core-v2/src/agent/mcp/session-config.ts deleted file mode 100644 index b8a7b1b21..000000000 --- a/packages/agent-core-v2/src/agent/mcp/session-config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { McpServerConfig } from './config-schema'; - -import { loadMcpServers } from './config-loader'; - -export interface SessionMcpConfig { - readonly servers: Record; -} - -export interface ResolveSessionMcpConfigInput { - readonly cwd: string; - readonly homeDir?: string; -} - -export async function resolveSessionMcpConfig( - input: ResolveSessionMcpConfigInput, -): Promise { - const servers = await loadMcpServers({ - cwd: input.cwd, - homeDir: input.homeDir, - }); - if (Object.keys(servers).length === 0) return undefined; - return { servers }; -} - -export function mergeCallerMcpServers( - base: SessionMcpConfig | undefined, - callerServers: Readonly> | undefined, -): SessionMcpConfig | undefined { - if (callerServers === undefined || Object.keys(callerServers).length === 0) { - return base; - } - return { - servers: { - ...base?.servers, - ...callerServers, - }, - }; -} diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts index 69df9c4e2..8e2aef9ed 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -32,8 +32,8 @@ import { type ExecutableToolResult, } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; -import { AlreadyAuthorizedError, type McpOAuthService } from '#/agent/mcp/oauth/service'; -import { qualifyMcpToolName } from '#/agent/mcp/tool-naming'; +import { AlreadyAuthorizedError, type McpOAuthService } from '#/mcpCore/oauth/service'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; /** * `ToolUpdate.customKind` emitted by the MCP auth tool when the OAuth diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index 7b066a7b7..8632cb30c 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -30,13 +30,13 @@ import { isAbortError } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract'; import { mcpResultToExecutableOutput } from '#/agent/mcp/output'; -import type { MCPClient, MCPToolResult } from '#/agent/mcp/types'; +import type { MCPClient, MCPToolResult } from '#/mcpCore/types'; import { isMcpConnectionClosedError, isMcpMalformedResultError, isMcpTransportFailure, probeMcpLiveness, -} from '#/agent/mcp/client-shared'; +} from '#/mcpCore/client-shared'; interface McpToolOptions { readonly originalsDir?: string; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts index a2c58567c..a2d8f132e 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts @@ -1,4 +1,6 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -9,7 +11,6 @@ import type { } from '#/agent/permissionPolicy/types'; import { fileAccesses, - findLocalGitWorkTreeMarker, hasGitPathComponent, isGitControlPath, } from './path-utils'; @@ -20,6 +21,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio constructor( @IHostEnvironment private readonly env: HostEnvironment, @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, ) {} async evaluate( @@ -36,7 +38,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio ); if (directGitAccess !== undefined) return { kind: 'ask' }; - const marker = await findLocalGitWorkTreeMarker(cwd); + const marker = await this.git.findWorkTree(cwd); if (marker === null) return undefined; const access = accesses.find((fileAccess) => isGitControlPath(fileAccess.path, marker, pathClass), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts index 11ec34a08..5b55977fb 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -1,5 +1,7 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { isWithinWorkspace } from '#/tool/path-access'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -8,10 +10,7 @@ import type { PermissionPolicy, PermissionPolicyResult, } from '#/agent/permissionPolicy/types'; -import { - findLocalGitWorkTreeMarker, - writeFileAccesses, -} from './path-utils'; +import { writeFileAccesses } from './path-utils'; export class GitCwdWriteApprovePermissionPolicyService implements PermissionPolicy { readonly name = 'git-cwd-write-approve'; @@ -19,6 +18,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli constructor( @IHostEnvironment private readonly env: HostEnvironment, @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, ) {} async evaluate( @@ -45,7 +45,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli return undefined; } - return (await findLocalGitWorkTreeMarker(cwd)) === null + return (await this.git.findWorkTree(cwd)) === null ? undefined : { kind: 'approve' }; } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts index 529fbdf61..b64439e54 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts @@ -1,8 +1,7 @@ -import { readFile, stat } from 'node:fs/promises'; -import * as nodePath from 'node:path'; import * as posixPath from 'node:path/posix'; import * as win32Path from 'node:path/win32'; +import type { GitWorkTree } from '#/app/git/workTree'; import type { ToolFileAccess } from '#/tool/toolContract'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { @@ -10,11 +9,6 @@ import { type PathClass, } from '#/tool/path-access'; -export interface PermissionGitWorkTreeMarker { - readonly dotGitPath: string; - readonly controlDirPath: string; -} - export function fileAccesses(context: ResolvedToolExecutionHookContext): ToolFileAccess[] { return ( context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ?? @@ -40,7 +34,7 @@ export function hasGitPathComponent( export function isGitControlPath( targetPath: string, - marker: PermissionGitWorkTreeMarker, + marker: GitWorkTree, pathClass: PathClass, ): boolean { return ( @@ -53,24 +47,6 @@ export function defaultPathClass(): PathClass { return process.platform === 'win32' ? 'win32' : 'posix'; } -export async function findLocalGitWorkTreeMarker( - cwd: string, -): Promise { - if (cwd.length === 0 || !nodePath.isAbsolute(cwd)) return null; - - let current = nodePath.normalize(cwd); - for (let depth = 0; depth < 256; depth += 1) { - const dotGitPath = nodePath.join(current, '.git'); - const marker = await probeLocalGitMarker(dotGitPath, current); - if (marker !== null) return marker; - - const parent = nodePath.dirname(current); - if (parent === current) return null; - current = parent; - } - return null; -} - function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass): string[] { return pathMod(pathClass) .relative(cwd, targetPath) @@ -81,32 +57,3 @@ function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass function pathMod(pathClass: PathClass): typeof posixPath { return pathClass === 'win32' ? win32Path : posixPath; } - -async function probeLocalGitMarker( - dotGitPath: string, - markerParent: string, -): Promise { - try { - const markerStat = await stat(dotGitPath); - if (markerStat.isDirectory()) return { dotGitPath, controlDirPath: dotGitPath }; - if (!markerStat.isFile()) return null; - - const content = await readFile(dotGitPath, 'utf8'); - const controlDirPath = parseLocalGitDir(content, markerParent); - return controlDirPath === undefined ? null : { dotGitPath, controlDirPath }; - } catch { - return null; - } -} - -function parseLocalGitDir(content: string, markerParent: string): string | undefined { - const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content; - const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim(); - if (line === undefined || !line.startsWith('gitdir:')) return undefined; - - const rawPath = line.slice('gitdir:'.length).trim(); - if (rawPath.length === 0) return undefined; - return nodePath.normalize( - nodePath.isAbsolute(rawPath) ? rawPath : nodePath.join(markerParent, rawPath), - ); -} diff --git a/packages/agent-core-v2/src/agent/plan/configSection.ts b/packages/agent-core-v2/src/agent/plan/configSection.ts index 04b1cfce0..18c184455 100644 --- a/packages/agent-core-v2/src/agent/plan/configSection.ts +++ b/packages/agent-core-v2/src/agent/plan/configSection.ts @@ -4,7 +4,7 @@ * Top-level boolean preference (`default_plan_mode` on disk, v1-compatible): * when `true`, every freshly created session starts in plan mode. Resumed / * forked sessions restore plan state from wire records and ignore this. Read by - * `sessionLifecycle` at session creation; runtime plan state lives on the wire + * `workspaceHandler` at session creation; runtime plan state lives on the wire * `PlanModel`, not here. */ diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 1781af018..31087362f 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -26,7 +26,7 @@ * `agent.status.updated` planMode slice — are NOT part of `apply`: they run * after `wire.dispatch` on the live path, and `wire.replay` rebuilds the * Model silently from the persisted `plan_mode.*` / `plan.revision` records - * (seeded by `sessionLifecycle`). The legacy `toReplay: plan_updated` + * (seeded by `workspaceHandler`). The legacy `toReplay: plan_updated` * projection is dropped (inert — nothing reads it). `plan.revision` carries * a `toEvent` so the live transcript projector can map it onto a marker plus * the plan badge; replay never emits it. Consumed by the Agent-scope diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 3fc8198b6..0b2d361d6 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -20,10 +20,10 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { IAgentPluginService } from './agentPlugin'; diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index ebd5619ac..260c30fef 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -2,8 +2,15 @@ * `profile` domain (L4) — system-prompt context assembly. * * Loads the AGENTS.md instruction hierarchy (user-level brand + generic files, - * then project-level files from the project root down to the cwd) and assembles + * then project-level files from the project root down to the cwd — the root + * discovered through the `git` domain's work-tree probe) and assembles * the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`. + * `agentsMdWatchRoots` exposes the watch plan for the probed file set so the + * Workspace-scope `workspaceInstructions` service can watch exactly what the + * loader reads, and `prepareSystemPromptContext` accepts a + * `preloadedAgentsMd` snapshot so the + * agent-side profile service can inject that workspace snapshot instead of + * re-reading the files. * * Runs on top of the os `IHostFileSystem` (for `readText` / `stat` / `readdir`) * plus the host's `homeDir` — supplied together as a small `ProfileContextDeps` @@ -18,6 +25,7 @@ import { dirname, join, normalize } from 'pathe'; +import { findGitWorkTree } from '#/app/git/workTree'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import type { SystemPromptContext } from './profile'; @@ -32,6 +40,8 @@ interface ProfileContextDeps { readonly homeDir: string; } +export type { ProfileContextDeps }; + export interface PreparedSystemPromptContext extends SystemPromptContext { readonly cwdListing?: string; readonly agentsMd?: string; @@ -41,6 +51,11 @@ export interface PreparedSystemPromptContext extends SystemPromptContext { export interface PrepareSystemPromptContextOptions { readonly additionalDirs?: readonly string[]; + /** + * A pre-loaded AGENTS.md result (e.g. the workspace instructions snapshot). + * When provided, the loader is not re-run — the caller owns freshness. + */ + readonly preloadedAgentsMd?: LoadedAgentsMd; } export async function prepareSystemPromptContext( @@ -52,7 +67,9 @@ export async function prepareSystemPromptContext( const additionalDirs = dedupeDirs(options?.additionalDirs ?? []); const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([ listDirectory(deps, workDir, { collapseHiddenDirs: true }), - loadAgentsMdForRoots(deps, brandHome, [workDir]), + options?.preloadedAgentsMd !== undefined + ? Promise.resolve(options.preloadedAgentsMd) + : loadAgentsMdForRoots(deps, brandHome, [workDir]), loadAdditionalDirsInfo(deps, additionalDirs), ]); return { @@ -77,7 +94,9 @@ interface LoadedAgentsMd { readonly warning: string | undefined; } -async function loadAgentsMdForRoots( +export type { LoadedAgentsMd }; + +export async function loadAgentsMdForRoots( deps: ProfileContextDeps, brandHome: string | undefined, workDirs: readonly string[], @@ -113,7 +132,7 @@ async function loadAgentsMdForRoots( for (const workDir of workDirs) { const rootWorkDir = normalize(workDir); - const projectRoot = await findProjectRoot(deps, rootWorkDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); for (const dir of dirs) { @@ -137,6 +156,50 @@ async function loadAgentsMdForRoots( return { content, warning }; } +/** + * The watch plan for {@link loadAgentsMdForRoots}: one entry per existing + * watch root with the candidate instruction files beneath it (brand dir, + * real home for the `.agents` generic files, and the project root for the + * root→leaf `.kimi-code/AGENTS.md` / `AGENTS.md` / `agents.md` chain) — + * regardless of existence or the first-existing-wins precedence the loader + * applies, since a higher-precedence file appearing later must still + * invalidate the snapshot. Consumers watch each root recursively and filter + * events to the candidates (watching a missing file directly silently never + * fires when its parent directory is missing too). + */ +export interface AgentsMdWatchRoot { + readonly root: string; + readonly candidates: readonly string[]; +} + +export async function agentsMdWatchRoots( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + const plan: AgentsMdWatchRoot[] = [ + { root: brandDir, candidates: [join(brandDir, 'AGENTS.md')] }, + { + root: realHome, + candidates: [join(realHome, '.agents', 'AGENTS.md'), join(realHome, '.agents', 'agents.md')], + }, + ]; + const rootWorkDir = normalize(workDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; + const projectCandidates: string[] = []; + for (const dir of dirsRootToLeaf(rootWorkDir, projectRoot)) { + projectCandidates.push( + join(dir, '.kimi-code', 'AGENTS.md'), + join(dir, 'AGENTS.md'), + join(dir, 'agents.md'), + ); + } + plan.push({ root: projectRoot, candidates: projectCandidates }); + return plan; +} + async function loadAdditionalDirsInfo( deps: ProfileContextDeps, additionalDirs: readonly string[], @@ -150,18 +213,6 @@ async function loadAdditionalDirsInfo( return sections.join('\n\n'); } -async function findProjectRoot(deps: ProfileContextDeps, 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; - } -} - function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { const dirs: string[] = []; let current = normalize(workDir); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 84f39fe8c..0751d0f33 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -38,7 +38,6 @@ export class ProfileError extends Error2 { } export interface AgentConfigData { - cwd: string; modelAlias?: string; modelCapabilities: ModelCapability; profileName?: string; @@ -47,7 +46,6 @@ export interface AgentConfigData { } export type AgentConfigUpdateData = Partial<{ - cwd: string; modelAlias: string; profileName: string; thinkingLevel: string; @@ -67,7 +65,6 @@ export interface ProfileData extends AgentConfigData { } export type ProfileUpdateData = Partial<{ - cwd: string; modelAlias: string; profileName: string; thinkingLevel: string; @@ -77,7 +74,6 @@ export type ProfileUpdateData = Partial<{ }>; export interface ProfileBindingSnapshot { - readonly cwd: string; readonly modelAlias?: string; readonly profileName?: string; readonly thinkingLevel: string; @@ -88,8 +84,6 @@ export interface ProfileBindingSnapshot { } export interface ProfileServiceOptions { - readonly cwd?: string | (() => string | undefined); - readonly chdir?: (cwd: string) => void | Promise; readonly emitStatusUpdated?: () => void; } @@ -117,7 +111,6 @@ export interface BindAgentInput { readonly model?: string; readonly thinking?: string; readonly strictThinking?: boolean; - readonly cwd?: string; } export interface IAgentProfileService { diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index 65a707b1b..3db302d59 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -2,7 +2,7 @@ * `profile` domain (L3) — wire Model (`ProfileModel`) and the `config.update` * Op (`configUpdate`) for the agent's persistent configuration slice. * - * Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`, + * Declares the persistent profile config — `modelAlias`, `profileName`, * the resolved base thinking effort, `systemPrompt`, and the profile * `disallowedTools` denylist and `subagents` delegation allowlist — as a wire * Model (initial `defaultProfileModel()`), plus the single Op whose `apply` is @@ -18,10 +18,13 @@ * `modelCapabilities` is intentionally NOT in the Model — it is * derived live from `IModelCatalog` so resume never pins stale capabilities. * Each `apply` returns the same reference when nothing changes so the wire's - * reference-equality gate stays quiet. The `chdir` side effect and the - * `agent.status.updated` emission are NOT part of `apply`: they run after + * reference-equality gate stays quiet. The `agent.status.updated` emission is + * NOT part of `apply`: it runs after * `wire.dispatch` on the live path only, so `wire.replay` rebuilds the Model - * silently. + * silently. The agent's working directory is deliberately NOT part of the + * binding: it is always the session's frozen cwd, read from `sessionContext` + * at render time rather than persisted here. Legacy `profile.bind` records + * that still carry a `cwd` field replay fine — the schema strips it. * * Also declares `ActiveToolsModel` (`readonly string[] | undefined`, initial * `undefined` = every tool active), the `tools.set_active_tools` whole-set @@ -42,7 +45,6 @@ import type { PayloadOf } from '#/wire/types'; import { ProfileError, ProfileErrors } from './profile'; export interface ProfileModelState { - readonly cwd?: string; readonly modelAlias?: string; readonly profileName?: string; readonly thinkingLevel: string; @@ -58,7 +60,6 @@ export const ProfileModel = defineModel('profile', () => ({ export const profileBind = ProfileModel.defineOp('profile.bind', { schema: z.object({ - cwd: z.string().optional(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom(), @@ -68,7 +69,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { subagents: z.array(z.string()).readonly().optional(), }), apply: (s, p) => ({ - cwd: p.cwd ?? s.cwd, modelAlias: p.modelAlias ?? s.modelAlias, profileName: p.profileName ?? s.profileName, thinkingLevel: p.thinkingEffort, @@ -80,7 +80,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { export const configUpdate = ProfileModel.defineOp('config.update', { schema: z.object({ - cwd: z.string().optional(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom().optional(), @@ -90,9 +89,6 @@ export const configUpdate = ProfileModel.defineOp('config.update', { }), apply: (s, p) => { let next: ProfileModelState | undefined; - if (p.cwd !== undefined && p.cwd !== s.cwd) { - next = { ...(next ?? s), cwd: p.cwd }; - } if (p.modelAlias !== undefined && p.modelAlias !== s.modelAlias) { next = { ...(next ?? s), modelAlias: p.modelAlias }; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 5e8c92855..efd2d6aef 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -21,9 +21,9 @@ * so no restore-ordering coupling with `userTool` arises. Profile and client * policy are persisted independently. The `agent.status.updated` * / `warning` events now ride `IEventBus` (`agent.status.updated` canonical in - * `usageOps`). `chdir` and - * `emitStatusUpdated` run live-only after the dispatch, so `wire.replay` - * rebuilds the Models silently; the same live-only path mirrors the resolved + * `usageOps`). `emitStatusUpdated` runs live-only after the dispatch, so + * `wire.replay` rebuilds the Models silently; the same live-only path mirrors + * the resolved * model protocol into the ambient telemetry context (`provider_type` / * `protocol`) whenever the model alias changes. * `bind()` is first-bind only — a profile is the session's identity: the @@ -31,11 +31,16 @@ * in the synchronous segment before the first dispatch, so concurrent binds * cannot both pass (an edge-level guard always leaves an interleaving * window); a same-name rebind keeps the persisted thinking effort unless the - * caller explicitly overrides it. Prompt builds inject the enabled plugins' + * caller explicitly overrides it. The AGENTS.md portion of the system-prompt + * context comes from the seeded `ISessionInstructionsProvider` (the + * workspace handler's shared, watch-refreshed snapshot — the working + * directory is always the session's frozen cwd, so the snapshot always + * applies), and the provider's change event drives a `refreshSystemPrompt`. Prompt builds inject the enabled plugins' * system-prompt sections (budget-capped, see `PLUGIN_SECTIONS_MAX_BYTES`); - * plugin changes reach the prompt when the session skill catalog re-pulls - * its plugin source on explicit plugin reload — the same point where plugin - * skills take effect. `refreshSystemPrompt` never rejects: a + * plugin changes reach the prompt when the skill catalog re-pulls its plugin + * source on explicit plugin reload (the Workspace-scope catalog forwards the + * plugin source's change through the session seed) — the same point where + * plugin skills take effect. `refreshSystemPrompt` never rejects: a * failed context build keeps the current prompt and surfaces a warning, * because the `[tools]` config watcher fires it voided (an unhandled * rejection would crash kap-server) and the Session tool-policy fan-out @@ -50,7 +55,7 @@ * The mutable plain-data state (`activeToolNamesOverlay` / `agentsMdWarning` * / the three emitted-warning dedupe sets) is registered into `agentState` * (`IAgentStateService`) and read/written through it; `optionsValue` (holds - * the `cwd` / `chdir` / `emitStatusUpdated` callbacks) and `activeProfile` + * the `cwd` / `emitStatusUpdated` callbacks) and `activeProfile` * (a `ResolvedAgentProfile` carrying the `systemPrompt` function) stay plain * fields because the container only holds pure data structures. Bound at * Agent scope. @@ -76,7 +81,8 @@ import { type ThinkingConfig, } from '#/kosong/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -86,10 +92,12 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IPluginService } from '#/app/plugin/plugin'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -100,7 +108,7 @@ import { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; import { IEventBus } from '#/app/event/eventBus'; import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; -import { prepareSystemPromptContext } from './context'; +import { prepareSystemPromptContext, type LoadedAgentsMd } from './context'; import type { ApplyProfileOptions, BindAgentInput, @@ -206,9 +214,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, - @IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService, + @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, @IAgentStateService private readonly states: IAgentStateService, @IHostIdentity private readonly hostIdentity: IHostIdentity, @IPluginService private readonly plugins: IPluginService, @@ -225,6 +235,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ event.waitUntil(this.refreshSystemPrompt()); }), ); + this._register( + // The workspace AGENTS.md snapshot changed (fs watch on the handler + // side): rebuild the system prompt off the fresh snapshot. + this.instructions.onDidChange(() => { + void this.refreshSystemPrompt(); + }), + ); this._register( this.config.onDidSectionChange(({ domain }) => { if (domain === TOOLS_SECTION) { @@ -272,8 +289,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ configure(options: ProfileServiceOptions): void { this.optionsValue = { - cwd: options.cwd ?? this.optionsValue.cwd, - chdir: options.chdir ?? this.optionsValue.chdir, emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated, }; } @@ -300,7 +315,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeToolNamesOverlay = undefined; this.wire.dispatch( profileBind({ - cwd: snapshot.cwd, modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingEffort: snapshot.thinkingLevel, @@ -311,7 +325,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }), ); this.afterConfigDispatch({ - cwd: snapshot.cwd, modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingLevel: snapshot.thinkingLevel, @@ -349,7 +362,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } await this.sessionToolPolicy.ready; - const context = await this.buildSystemPromptContext(profile, input.cwd); + const context = await this.buildSystemPromptContext(profile); this.assertBindable(profile.name); const currentProfileName = this.profileName; const systemPrompt = profile.systemPrompt(context); @@ -363,7 +376,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeToolNamesOverlay = undefined; this.wire.dispatch(profileBind({ - cwd: input.cwd, modelAlias: alias, profileName: profile.name, thinkingEffort: thinkingLevel, @@ -373,7 +385,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ subagents: profile.subagents, })); this.afterConfigDispatch({ - cwd: input.cwd, modelAlias: alias, profileName: profile.name, thinkingLevel, @@ -445,7 +456,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { - const context = await this.buildSystemPromptContext(profile, undefined, options); + const context = await this.buildSystemPromptContext(profile, options); this.useProfile(profile, context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); @@ -458,7 +469,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ let context: SystemPromptContext; try { - context = await this.buildSystemPromptContext(profile, this.cwd); + context = await this.buildSystemPromptContext(profile); } catch (error) { this.eventBus.publish({ type: 'warning', @@ -483,7 +494,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ data(): ProfileData { const model = this.tryResolveRawModel(); return { - cwd: this.cwd, modelAlias: this.modelAlias, modelCapabilities: model?.capabilities ?? UNKNOWN_CAPABILITY, profileName: this.profileName, @@ -583,7 +593,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const payload: { -readonly [K in keyof PayloadOf]: PayloadOf[K]; } = {}; - if (changed.cwd !== undefined) payload.cwd = changed.cwd; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { @@ -600,9 +609,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private afterConfigDispatch(changed: Omit): void { - if (changed.cwd !== undefined) { - void this.optionsValue.chdir?.(changed.cwd); - } if (changed.modelAlias !== undefined) { const model = this.tryResolveRawModel(); this.telemetryContext.set({ @@ -678,10 +684,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return this.wire.getModel(ProfileModel); } - private get cwd(): string { - return this.profileState.cwd ?? this.readConfiguredCwd() ?? ''; - } - private get model(): string { const modelAlias = this.modelAlias; if (modelAlias === undefined) { @@ -859,21 +861,26 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private async buildSystemPromptContext( profile: ResolvedAgentProfile, - cwd?: string, options?: ApplyProfileOptions, ): Promise { - const effectiveCwd = cwd ?? this.sessionContext.cwd; + // The working directory is always the session's frozen cwd, so the + // workspace instructions snapshot (which covers the handler root) always + // applies. + const preloadedAgentsMd = await this.workspaceInstructionsSnapshot(); const base = await prepareSystemPromptContext( { fs: this.fs, homeDir: this.env.homeDir }, - effectiveCwd, + this.sessionContext.cwd, this.bootstrap.homeDir, - { additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs }, + { + additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs, + preloadedAgentsMd, + }, ); const skills = await this.resolveSkillListing(); const pluginSections = await this.resolvePluginSections(); return { ...base, - cwd: effectiveCwd, + cwd: this.sessionContext.cwd, osKind: this.env.osKind, shellName: this.env.shellName, shellPath: this.env.shellPath, @@ -886,6 +893,14 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }; } + private async workspaceInstructionsSnapshot(): Promise { + await this.instructions.ready; + return { + content: this.instructions.agentsMd ?? '', + warning: this.instructions.agentsMdWarning, + }; + } + private isToolActiveForProfile( profile: ResolvedAgentProfile, name: string, @@ -893,6 +908,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ): boolean { return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), @@ -941,11 +957,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } return parts.join('\n\n'); } - - private readConfiguredCwd(): string | undefined { - const cwd = this.optionsValue.cwd; - return typeof cwd === 'function' ? cwd() : cwd; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 0b6668471..d00bb433c 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -24,7 +24,6 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { SwarmModeTrigger } from '#/agent/swarm/swarm'; import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract'; import type { ResolvedConfig } from '#/app/config/config'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { ExperimentalFeatureState } from '#/app/flag/flag'; import type { ResumeSessionResult } from '#/agent/replayBuilder/types'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -65,7 +64,6 @@ export interface CreateSessionPayload { readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; readonly metadata?: JsonObject | undefined; - readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; readonly client?: ClientTelemetryInfo | undefined; } @@ -80,7 +78,6 @@ export interface ArchiveSessionPayload { export interface ResumeSessionPayload { readonly sessionId: string; - readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; } @@ -257,18 +254,6 @@ export interface GetPluginInfoPayload { export type ReloadPluginsResult = ReloadSummary; export type { PluginSummary, PluginInfo }; -export interface AddAdditionalDirPayload { - readonly path: string; - readonly persist: boolean; -} - -export interface AddAdditionalDirResult { - readonly additionalDirs: readonly string[]; - readonly projectRoot: string; - readonly configPath: string; - readonly persisted: boolean; -} - export interface RenameSessionPayload { readonly title: string; } @@ -336,7 +321,6 @@ export interface SessionAPI extends AgentAPIWithId { reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; generateAgentsMd: (payload: EmptyPayload) => void; getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; - addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; } type SessionAPIWithId = WithSessionId; diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts index 1ee66e1fc..751553641 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts @@ -3,7 +3,8 @@ * * Owns the activation pass that turns the module-level `registerAgentToolService` * contributions (`toolRegistry`, L3) into entries of the per-agent runtime - * registry: a contribution activates only when its `when` predicate holds + * registry: a contribution activates only when its `when` predicate holds, + * the workspace os-level veto (`sessionToolPolicyGate`) does not disable it, * and its declared `name` is allowed by the bound Profile's tool policy * (`profile`, L4). `AgentLifecycleService.create` awaits one activation pass * after restore and profile binding, so an Agent's tools reflect the Profile diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index 840e5ef7e..643789448 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -2,7 +2,8 @@ * `toolActivation` domain (L4) — `IAgentToolActivationService` implementation. * * Iterates the `toolRegistry` contribution table and, for each entry allowed - * by the bound Profile's tool policy (`profile`), resolves the Agent-scope + * by the workspace os-level veto (the seeded `sessionToolPolicyGate`) AND + * the bound Profile's tool policy (`profile`), resolves the Agent-scope * service through the container — nothing constructs the tool before this * `accessor.get` — and registers the real instance into the runtime * registry. @@ -30,6 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { isToolActive } from '#/agent/toolPolicy/evaluate'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IAgentToolActivationService } from './toolActivation'; @@ -40,6 +42,7 @@ export class AgentToolActivationService extends Disposable implements IAgentTool @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IEventBus eventBus: IEventBus, ) { super(); @@ -53,10 +56,15 @@ export class AgentToolActivationService extends Disposable implements IAgentTool activate(): Promise { const data = this.profile.data(); const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools }; + const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools }; this.instantiationService.invokeFunction((accessor) => { for (const { id, options } of getAgentToolContributions()) { const source = options.source ?? 'builtin'; if (this.toolRegistry.resolve(options.name) !== undefined) continue; + // The workspace (os-level) veto outranks the profile: a disabled tool + // never activates, so it never reaches the registry (and therefore + // the schema) at all. + if (!isToolActive(workspaceVeto, options.name, source)) continue; if (!isToolActive(policy, options.name, source)) continue; if (options.when !== undefined && !options.when(accessor)) continue; const tool = accessor.get(id); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts index e119dc6a2..0c6958ffc 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts @@ -3,8 +3,9 @@ * * Applies allowlists and denylists with builtin/MCP matching semantics shared * by Agent authorization, profile prompt construction, and child-agent setup. - * `isToolActiveComposed` intersects the three policy layers (profile, global - * `[tools]` config, Session denylist) so every consumer evaluates the same + * `isToolActiveComposed` intersects the policy layers (workspace os-level + * veto, profile, global `[tools]` config, Session denylist — the workspace + * veto first, outranking the rest) so every consumer evaluates the same * combination instead of re-implementing it. An empty/absent global `enabled` * list means unconstrained — an explicit empty list must never disable * everything. @@ -58,6 +59,12 @@ export interface GlobalToolsPolicy { } export interface ToolPolicyLayers { + /** + * The workspace (os-level) veto: tools the runtime / workspace disables. + * Evaluated FIRST — it outranks every other layer, so a workspace-disabled + * tool is inactive no matter what profile, config, or session layers say. + */ + readonly workspaceDisabledTools?: readonly string[]; readonly profile: ToolActivationPolicy; readonly global?: GlobalToolsPolicy; readonly sessionDisabledTools?: readonly string[]; @@ -69,6 +76,7 @@ export function isToolActiveComposed( source: ToolSource = 'builtin', ): boolean { return ( + isToolActive({ disallowedTools: layers.workspaceDisabledTools }, name, source) && isToolActive(layers.profile, name, source) && isToolActive( { diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts index 994933ffa..7e89510bd 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -1,12 +1,14 @@ /** * `toolPolicy` domain (L4) — Agent-scope tool authorization service. * - * Intersects the bound profile policy, global `[tools]` configuration, and - * Session denylist (composed by `isToolActiveComposed` in `./evaluate`), and - * installs the resulting authorization check into the L3 executor preflight so - * direct tool calls cannot bypass schema filtering. Disclosure entries retain - * their implicit availability when a profile allowlist omits them, while - * explicit deny layers still apply. + * Intersects the workspace os-level veto (the seeded `sessionToolPolicyGate`, + * which outranks everything below it), the bound profile policy, global + * `[tools]` configuration, and Session denylist (composed by + * `isToolActiveComposed` in `./evaluate`), and installs the resulting + * authorization check into the L3 executor preflight so direct tool calls + * cannot bypass schema filtering. Disclosure entries retain their implicit + * availability when a profile allowlist omits them, while explicit deny + * layers still apply. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -16,6 +18,7 @@ import { TOOLS_SECTION, type ToolsConfig } from './configSection'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IConfigService } from '#/app/config/config'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import type { ToolSource } from '#/tool/toolContract'; @@ -29,6 +32,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli @IAgentProfileService private readonly profile: IAgentProfileService, @IConfigService private readonly config: IConfigService, @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); @@ -61,6 +65,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli const profile = this.profile.data(); return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile: { disallowedTools: profile.disallowedTools }, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), @@ -77,6 +82,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli ): boolean { return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 6d4d62b5e..ff249b2f5 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -276,7 +276,6 @@ export class SubagentTool implements ISubagentTool { profile: profile.name, model: binding.model, thinking: binding.thinking, - cwd: own.cwd, }, labels: subagentLabels(this.callerAgentId), }); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts deleted file mode 100644 index 3fa3661e9..000000000 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * `agentFileCatalog` domain (L3) — agent-profile source contract. - * - * `IAgentProfileSource` is the producer half of the agent-file subsystem: each - * source loads an `AgentProfileContribution` and advertises a `priority` so the - * Session catalog can ordered-merge contributions (higher priority wins name - * collisions). Mirrors `skillCatalog/skillSource`, with one deliberate - * deviation: `explicit` outranks every other source (in the skill system it - * aliases `user`) because `--agent-file` is a one-shot command-line intent that - * must always win. Concrete sources (user at App scope; plugin / project / - * extra / explicit at Session scope) each bind their own DI token extending - * this contract. - * - * A source may mark `load()` failures as `fatal`: the Session catalog lets - * them propagate into `ready` so awaiters see the error (`explicit` does — - * `--agent-file` is an explicit user intent that must not be silently - * dropped); without it a failure degrades to a warning and keeps any - * previously loaded contribution, because directory sources must never poison - * a session over a transient fs error. `profilesFromDiscovery` binds each - * profile's `${base_prompt}` placeholder lazily at render time, so it always - * reflects the effective default profile (builtin, or the `SYSTEM.md` - * override) rather than any file-based definition. - */ - -import type { Event } from '#/_base/event'; -import type { - AgentProfile, - AgentProfileContext, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; - -import { agentProfileFromFile } from './agentProfileFromFile'; -import type { AgentFileDiscoveryResult, SkippedAgentFile } from './types'; - -export interface AgentProfileContribution { - readonly profiles: readonly AgentProfile[]; - readonly skipped?: readonly SkippedAgentFile[]; - readonly scannedRoots?: readonly string[]; -} - -export const AGENT_PROFILE_SOURCE_PRIORITY = { - plugin: 5, - user: 10, - extra: 20, - project: 30, - explicit: 40, -} as const; - -export interface IAgentProfileSource { - readonly _serviceBrand: undefined; - readonly id: string; - readonly priority: number; - readonly onDidChange?: Event; - readonly fatal?: boolean; - load(): Promise; -} - -export function profilesFromDiscovery( - result: AgentFileDiscoveryResult, - basePrompt: (context: AgentProfileContext) => string, -): AgentProfileContribution { - return { - profiles: result.agents.map((definition) => agentProfileFromFile(definition, basePrompt)), - skipped: result.skipped, - scannedRoots: result.scannedRoots, - }; -} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts deleted file mode 100644 index cbd8840cc..000000000 --- a/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * `agentFileCatalog` domain (L3) — user `IAgentProfileSource` producer. - * - * Discovers user agent profiles through `bootstrap` home paths and `hostFs`, - * reports skipped files through `log`, and appends the `/SYSTEM.md` - * prompt-override profile (synthesized against the builtin default from the - * App profile catalog) after the scanned profiles so it wins same-name - * collisions within this contribution. Also exposes the effective default - * profile — the `SYSTEM.md` override when present, else the builtin default, - * refreshed on each `load()` pass — so every agent-file source can back - * `${base_prompt}` with it. Bound at App scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { - IAgentProfileCatalogService, - type AgentProfile, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; - -import { discoverAgentFiles } from './agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from './agentProfileSource'; -import { userAgentRoots } from './agentRoots'; -import { loadSystemMdProfile } from './systemFile'; - -export interface IUserFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; - getDefaultProfile(): AgentProfile; -} - -export const IUserFileAgentSource: ServiceIdentifier = - createDecorator('userFileAgentSource'); - -export class UserFileAgentSource implements IUserFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'user'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user; - - private defaultProfile: AgentProfile; - - constructor( - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, - ) { - this.defaultProfile = builtin.getDefault(); - } - - getDefaultProfile(): AgentProfile { - return this.defaultProfile; - } - - async load(): Promise { - const roots = await userAgentRoots( - this.fs, - this.bootstrap.homeDir, - this.bootstrap.osHomeDir, - (message, error) => { - this.log.warn(message, error); - }, - ); - const systemMd = await loadSystemMdProfile( - this.fs, - this.bootstrap.homeDir, - this.builtin.getDefault(), - (message) => this.log.warn(message), - ); - this.defaultProfile = systemMd ?? this.builtin.getDefault(); - const contribution = profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), - (context) => this.defaultProfile.systemPrompt(context), - ); - if (systemMd === undefined) return contribution; - return { ...contribution, profiles: [...contribution.profiles, systemMd] }; - } -} - -registerScopedService( - LifecycleScope.App, - IUserFileAgentSource, - UserFileAgentSource, - ScopeActivation.OnScopeCreated, - 'agentFileCatalog', -); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 5b07df921..c0735dcdf 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -1,6 +1,6 @@ /** - * `agentProfileCatalog` domain (L3) — App-scope registry of named agent - * profiles. + * `agentProfileCatalog` domain (L3) — the agent-profile domain types and the + * App-scope extension point (`IAgentProfileRegistry`). * * A profile is "how an Agent runs": the full system prompt it renders for a * given context, the tool set it may use, plus optional per-invocation and @@ -22,17 +22,17 @@ * an allowlist of subagent profile names the agent may delegate to * (`undefined` = any type). * - * Profiles are contributed at module load via `registerAgentProfile(...)`, the - * same "import = register" pattern used by `registerAgentToolService` and - * `registerConfigSection`. `AgentProfileCatalogService` consumes the accumulated - * contributions on construction and exposes `get(name)` / `getDefault()` / - * `list()` to callers (the `Agent` tool, the swarm scheduler, and the per-agent - * profile binding). Contributions are keyed by `name`; a later-registered - * profile with the same name overrides an earlier one. + * Profiles reach agents through the Contribution / Registry / Catalog + * extension point: loaders (builtin code contributions via + * `registerAgentProfile(...)`, plugin / user file scans at App scope, + * workspace / extra / explicit file scans at Workspace scope) register + * `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, + * keyed by source id; the Session-scope `ISessionAgentProfileCatalog` + * projects the registry into the merged, name-deduped read view that + * consumers (the `Agent` tool, the swarm scheduler, the per-agent profile + * binding) resolve profiles through. */ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - import type { ILogger } from '#/_base/log/log'; import type { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -82,14 +82,3 @@ export interface AgentProfile { readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; readonly summaryPolicy?: AgentProfileSummaryPolicy; } - -export interface IAgentProfileCatalogService { - readonly _serviceBrand: undefined; - - get(name: string): AgentProfile | undefined; - getDefault(): AgentProfile; - list(): readonly AgentProfile[]; -} - -export const IAgentProfileCatalogService: ServiceIdentifier = - createDecorator('agentProfileCatalogService'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts deleted file mode 100644 index 5e7dac767..000000000 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `agentProfileCatalog` domain (L3) — `IAgentProfileCatalogService` impl. - * - * Snapshots the module-level contributions on construction. Register-after- - * construction is not supported: like `IAgentToolRegistryService`, the - * expectation is that contributions accumulate at import time before the - * container resolves the service. `getDefault()` throws a plain `Error` when - * the builtin default profile is missing — that is a programming-time - * invariant violation, not a request failure, so it does not warrant a wire - * error code. - */ - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; - -import type { AgentProfile } from './agentProfileCatalog'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from './agentProfileCatalog'; -import { getAgentProfileContributions } from './contribution'; - -export class AgentProfileCatalogService implements IAgentProfileCatalogService { - declare readonly _serviceBrand: undefined; - - private readonly byName: Map; - private readonly ordered: readonly AgentProfile[]; - - constructor() { - const contributions = getAgentProfileContributions(); - this.ordered = [...contributions]; - this.byName = new Map(this.ordered.map((def) => [def.name, def])); - } - - get(name: string): AgentProfile | undefined { - return this.byName.get(name); - } - - getDefault(): AgentProfile { - const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME); - if (profile === undefined) { - throw new Error( - `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, - ); - } - return profile; - } - - list(): readonly AgentProfile[] { - return this.ordered; - } -} - -registerScopedService( - LifecycleScope.App, - IAgentProfileCatalogService, - AgentProfileCatalogService, - ScopeActivation.OnScopeCreated, - 'agentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts new file mode 100644 index 000000000..17533f5b1 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts @@ -0,0 +1,42 @@ +/** + * `agentProfileCatalog` domain (L3) — the agent-profile Contribution shape and + * source priorities. + * + * `AgentProfileContribution` is the Contribution of the agent-profile + * extension point: the plain data structure a loader registers into the + * App-scope `IAgentProfileRegistry` under its source id. It is pure payload — + * the source id and priority are registration metadata passed to `register`, + * never part of the contribution. Name-level dedup is NOT done here or in the + * registry; it is the Session catalog's projection job. The shape lives beside + * the registry (not beside any loader) because it is the registry's contract + * with every contributor, whatever scope the contributor runs in. + * + * `AGENT_PROFILE_SOURCE_PRIORITY` orders the sources for that projection + * (higher wins name collisions), with one deliberate deviation from the skill + * system: `explicit` outranks every other source (in the skill system it + * aliases `user`) because `--agent-file` is a one-shot command-line intent + * that must always win. + */ + +import type { AgentProfile } from './agentProfileCatalog'; + +/** A file a discovery pass could not parse, paired with the reason. */ +export interface SkippedAgentFile { + readonly path: string; + readonly reason: string; +} + +export interface AgentProfileContribution { + readonly profiles: readonly AgentProfile[]; + readonly skipped?: readonly SkippedAgentFile[]; + readonly scannedRoots?: readonly string[]; +} + +export const AGENT_PROFILE_SOURCE_PRIORITY = { + builtin: 0, + plugin: 5, + user: 10, + extra: 20, + workspace: 30, + explicit: 40, +} as const; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts new file mode 100644 index 000000000..ccf1c8105 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts @@ -0,0 +1,64 @@ +/** + * `agentProfileCatalog` domain (L3) — `IAgentProfileRegistry` contract. + * + * The Registry of the Contribution / Registry / Catalog extension-point + * pattern for agent profiles. A contribution is a plain data structure + * (`AgentProfileContribution`) offered by a loader; the registry stores at + * most one contribution per (`sourceId`, `workspaceKey`) pair — re-registering + * replaces the previous entry, which is the only dedup this layer performs. + * Name-level dedup, priority ordering, and the builtin-override rule are the + * Catalog's projection job (`ISessionAgentProfileCatalog`), never the + * registry's. + * + * Bound at App scope so contributors from ANY scope can register: App loaders + * (builtin / plugin / user) register global contributions (`workspaceKey` + * absent), while each Workspace-scope loader registers its workspace-local + * contribution tagged with the handler's `workspaceKey`, and multiple + * workspaces never collide. `register` returns a handle whose `dispose` + * unregisters — but only the entry it registered, so a stale handle can never + * evict a newer re-registration. Every mutation fires `onDidChange` with the + * affected (sourceId, workspaceKey) so session catalogs can re-project. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import type { AgentProfileContribution } from './agentProfileContribution'; + +export interface AgentProfileRegistration { + readonly sourceId: string; + readonly priority: number; + /** Absent = global contribution; present = specific to one workspace handler. */ + readonly workspaceKey?: string; + readonly contribution: AgentProfileContribution; +} + +export interface AgentProfileRegistryChange { + readonly sourceId: string; + readonly workspaceKey?: string; +} + +export interface RegisterAgentProfileOptions { + readonly priority?: number; + /** Tag a workspace-local contribution; global contributors omit it. */ + readonly workspaceKey?: string; +} + +export interface IAgentProfileRegistry { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + register( + sourceId: string, + contribution: AgentProfileContribution, + options?: RegisterAgentProfileOptions, + ): IDisposable; + + unregister(sourceId: string, workspaceKey?: string): void; + + entries(): readonly AgentProfileRegistration[]; +} + +export const IAgentProfileRegistry: ServiceIdentifier = + createDecorator('agentProfileRegistry'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts new file mode 100644 index 000000000..d670e59d6 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts @@ -0,0 +1,81 @@ +/** + * `agentProfileCatalog` domain (L3) — `IAgentProfileRegistry` impl. + * + * App-scope singleton backed by the generic `ContributionRegistry`: storage + * keys encode the (sourceId, workspaceKey) pair so a workspace-local source id + * (`workspace`, `extra`, `explicit`) coexists across handlers, while global + * sources (`builtin`, `plugin`, `user`) register once. The registry is pure + * storage — merging, name dedup, and override rules live in the Session-scope + * catalog projection. + */ + +import { ContributionRegistry } from '#/_base/contribution/registry'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { AgentProfileContribution } from './agentProfileContribution'; + +import type { + AgentProfileRegistration, + AgentProfileRegistryChange, + IAgentProfileRegistry, + RegisterAgentProfileOptions, +} from './agentProfileRegistry'; +import { IAgentProfileRegistry as IAgentProfileRegistryDecorator } from './agentProfileRegistry'; + +function encodeKey(sourceId: string, workspaceKey: string | undefined): string { + return JSON.stringify([sourceId, workspaceKey ?? null]); +} + +function decodeKey(key: string): AgentProfileRegistryChange { + const [sourceId, workspaceKey] = JSON.parse(key) as [string, string | null]; + return { sourceId, workspaceKey: workspaceKey ?? undefined }; +} + +export class AgentProfileRegistryService + extends Disposable + implements IAgentProfileRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly registry = this._register( + new ContributionRegistry(), + ); + + readonly onDidChange: Event = (listener, thisArg, disposables) => + this.registry.onDidChange( + (key) => listener.call(thisArg, decodeKey(key)), + undefined, + disposables, + ); + + register( + sourceId: string, + contribution: AgentProfileContribution, + options?: RegisterAgentProfileOptions, + ): IDisposable { + const registration: AgentProfileRegistration = { + sourceId, + priority: options?.priority ?? 0, + workspaceKey: options?.workspaceKey, + contribution, + }; + return this.registry.register(encodeKey(sourceId, options?.workspaceKey), registration); + } + + unregister(sourceId: string, workspaceKey?: string): void { + this.registry.unregister(encodeKey(sourceId, workspaceKey)); + } + + entries(): readonly AgentProfileRegistration[] { + return this.registry.entries().map((entry) => entry.contribution); + } +} + +registerScopedService( + LifecycleScope.App, + IAgentProfileRegistryDecorator, + AgentProfileRegistryService, + ScopeActivation.OnScopeCreated, + 'agentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts new file mode 100644 index 000000000..7601b8435 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts @@ -0,0 +1,28 @@ +/** + * `agentProfileCatalog` domain (L3) — `IBuiltinAgentProfileLoader` contract. + * + * The builtin loader of the agent-profile extension point: owns the global + * `builtin` contribution (priority 0) in the App-scope `IAgentProfileRegistry` + * — the code-defined profiles accumulated at module load via + * `registerAgentProfile(...)`. Also exposes the static `get` / `getDefault` / + * `list` read view for loader-time consumers that need the builtin default + * before any session catalog exists (`UserAgentProfileLoader`'s `SYSTEM.md` + * synthesis). App-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { AgentProfile } from './agentProfileCatalog'; + +export const BUILTIN_AGENT_PROFILE_SOURCE_ID = 'builtin'; + +export interface IBuiltinAgentProfileLoader { + readonly _serviceBrand: undefined; + + get(name: string): AgentProfile | undefined; + getDefault(): AgentProfile; + list(): readonly AgentProfile[]; +} + +export const IBuiltinAgentProfileLoader: ServiceIdentifier = + createDecorator('builtinAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts new file mode 100644 index 000000000..65844a526 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts @@ -0,0 +1,75 @@ +/** + * `agentProfileCatalog` domain (L3) — `IBuiltinAgentProfileLoader` implementation. + * + * Snapshots the module-level contributions (`registerAgentProfile`, the + * "import = register" pattern) on construction and registers them into + * `IAgentProfileRegistry`. Register-after-construction is not supported: like + * `IAgentToolRegistryService`, contributions are expected to accumulate at + * import time before the container resolves the service. `getDefault()` + * throws a plain `Error` when the builtin default profile is missing — a + * programming-time invariant violation, not a request failure. Bound at App + * scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { AgentProfile } from './agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from './agentProfileCatalog'; +import { AGENT_PROFILE_SOURCE_PRIORITY } from './agentProfileContribution'; +import { IAgentProfileRegistry } from './agentProfileRegistry'; +import { + BUILTIN_AGENT_PROFILE_SOURCE_ID, + IBuiltinAgentProfileLoader, +} from './builtinAgentProfileLoader'; +import { getAgentProfileContributions } from './contribution'; + +export class BuiltinAgentProfileLoaderService + extends Disposable + implements IBuiltinAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + private readonly byName: Map; + private readonly ordered: readonly AgentProfile[]; + + constructor(@IAgentProfileRegistry registry: IAgentProfileRegistry) { + super(); + const contributions = getAgentProfileContributions(); + this.ordered = [...contributions]; + this.byName = new Map(this.ordered.map((def) => [def.name, def])); + this._register( + registry.register( + BUILTIN_AGENT_PROFILE_SOURCE_ID, + { profiles: this.ordered }, + { priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin }, + ), + ); + } + + get(name: string): AgentProfile | undefined { + return this.byName.get(name); + } + + getDefault(): AgentProfile { + const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new Error( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly AgentProfile[] { + return this.ordered; + } +} + +registerScopedService( + LifecycleScope.App, + IBuiltinAgentProfileLoader, + BuiltinAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'agentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 509b9566d..530ff9eea 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -71,10 +71,6 @@ export interface IBootstrapService { readonly logsDir: string; getEnv(name: string): string | undefined; scope(name: PersistenceScopeName): string; - sessionScope(workspaceId: string, sessionId: string): string; - agentScope(workspaceId: string, sessionId: string, agentId: string): string; - sessionDir(workspaceId: string, sessionId: string): string; - agentHomedir(workspaceId: string, sessionId: string, agentId: string): string; readonly configKey: string; } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index afeb61eca..13dd69b33 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -2,13 +2,11 @@ * `bootstrap` domain (L1) — `IBootstrapService` implementation. * * Holds the resolved startup snapshot from the seeded `IBootstrapOptions` and - * exposes the host facts, app path layout, and semantic scope mapping. All - * `scope*(...)` methods and `configKey` are computed once at construction so - * business code can read them synchronously. Path fields (`homeDir` / `*Dir` / - * `configPath`) are kept alongside for now to ease migration, but new business - * code should prefer `scope(name)` / `sessionScope(...)` / `agentScope(...)` — - * only the file-only accessors (`sessionDir` / `agentHomedir`) still hand out - * absolute paths, for the small number of legacy APIs that need them. + * exposes the host facts, app path layout, and top-level scope mapping. All + * `scope(name)` values and `configKey` are computed once at construction so + * business code can read them synchronously. Session/agent persistence + * addressing is NOT here — it derives from the workspace handler's + * persistence scope (`workspaceHandler` addressing). * * Bound at App scope. */ @@ -62,7 +60,7 @@ export class BootstrapService implements IBootstrapService { this.configKey = basename(options.configPath); this.scopes = { config: '', - sessions: relative(options.homeDir, this.sessionsDir), + sessions: relative(options.homeDir, join(options.homeDir, 'sessions')), blobs: relative(options.homeDir, this.blobsDir), store: relative(options.homeDir, this.storeDir), logs: relative(options.homeDir, this.logsDir), @@ -79,22 +77,6 @@ export class BootstrapService implements IBootstrapService { scope(name: PersistenceScopeName): string { return this.scopes[name]; } - - sessionScope(workspaceId: string, sessionId: string): string { - return join(this.scopes.sessions, workspaceId, sessionId); - } - - agentScope(workspaceId: string, sessionId: string, agentId: string): string { - return join(this.sessionScope(workspaceId, sessionId), 'agents', agentId); - } - - sessionDir(workspaceId: string, sessionId: string): string { - return join(this.homeDir, this.sessionScope(workspaceId, sessionId)); - } - - agentHomedir(workspaceId: string, sessionId: string, agentId: string): string { - return join(this.homeDir, this.agentScope(workspaceId, sessionId, agentId)); - } } registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, ScopeActivation.OnScopeCreated, 'bootstrap'); diff --git a/packages/agent-core-v2/src/app/gateway/gateway.ts b/packages/agent-core-v2/src/app/gateway/gateway.ts index a4a9437fd..80dab0fbe 100644 --- a/packages/agent-core-v2/src/app/gateway/gateway.ts +++ b/packages/agent-core-v2/src/app/gateway/gateway.ts @@ -2,8 +2,9 @@ * `gateway` domain (L7) — REST/WS gateways. * * Defines the public contracts of the gateway layer: the `IRestGateway` / - * `IWSGateway` entry points. Session scope creation is owned by - * `sessionLifecycle`; the gateway resolves sessions through it. + * `IWSGateway` entry points. Session scope creation is owned by the workspace + * handler (`workspaceHandler`); the gateway resolves sessions through the live + * handler registry (`workspaceLifecycle`). * App-scoped — shared across the application. */ diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 954aeb904..8fec17b53 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -1,7 +1,8 @@ /** * `gateway` domain (L7) — `IRestGateway` / `IWSGateway` implementations. * - * Owns the REST/WS entry points; resolves sessions through `sessionLifecycle`, + * Owns the REST/WS entry points; resolves sessions through the live handler + * registry (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), * agents through `agentLifecycle`, drives turns through `prompt` / `loop`, * and flushes logs through `log`. Bound at App scope. * @@ -18,7 +19,8 @@ import { } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ILogService } from '#/_base/log/log'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -28,12 +30,12 @@ export class RestGateway implements IRestGateway { declare readonly _serviceBrand: undefined; constructor( - @ISessionLifecycleService private readonly sessions: ISessionLifecycleService, + @IWorkspaceLifecycleService private readonly workspaceLifecycle: IWorkspaceLifecycleService, @ILogService private readonly log: ILogService, ) { } private agent(sessionId: string, agentId: string): IAgentScopeHandle { - const session = this.sessions.get(sessionId); + const session = this.liveSession(sessionId); if (session === undefined) throw new Error(`unknown session '${sessionId}'`); const agents = session.accessor.get(IAgentLifecycleService); const agent = agents.get(agentId); @@ -41,6 +43,14 @@ export class RestGateway implements IRestGateway { return agent; } + private liveSession(sessionId: string) { + for (const handler of this.workspaceLifecycle.handlers.list()) { + const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + if (handle !== undefined) return handle; + } + return undefined; + } + async prompt( sessionId: string, agentId: string, @@ -78,11 +88,11 @@ export class RestGateway implements IRestGateway { return Promise.resolve(); } getStatus(sessionId: string): Promise { - return Promise.resolve(this.sessions.get(sessionId) !== undefined); + return Promise.resolve(this.liveSession(sessionId) !== undefined); } async flushLogs(sessionId: string): Promise { - const session = this.sessions.get(sessionId); + const session = this.liveSession(sessionId); if (session === undefined) return; await session.accessor.get(ILogService).flush(); } @@ -96,10 +106,6 @@ export class WSGateway implements IWSGateway { declare readonly _serviceBrand: undefined; private readonly connections = new Set(); - constructor( - @ISessionLifecycleService _sessions: ISessionLifecycleService, - ) { } - connect(connectionId: string): void { this.connections.add(connectionId); } diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 1063cef0f..7bf0ef039 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -2,17 +2,23 @@ * `git` domain (L1) — git integration for a repository on the local disk. * * Defines the `IGitService` that runs `git status` / `git diff` (plus `gh pr - * view`) against a repository identified by an absolute `cwd`. App-scoped; it - * spawns `git` / `gh` through the `os/interface` process service rather than a - * Session's execution environment, so it never depends on a Session. Path - * confinement is the caller's responsibility — the service receives - * already-resolved absolute `cwd` and repo-relative paths. + * view`) against a repository identified by an absolute `cwd`, and discovers + * the enclosing git work tree of a directory (`findWorkTree`, the DI entry + * point over the pure `workTree` probe). App-scoped; it spawns `git` / `gh` + * through the `os/interface` process service rather than a Session's + * execution environment, so it never depends on a Session. Path confinement + * is the caller's responsibility — the service receives already-resolved + * absolute `cwd` and repo-relative paths. */ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { GitWorkTree } from './workTree'; + +export type { GitWorkTree } from './workTree'; + export const fsGitStatusSchema = z.enum([ 'clean', 'modified', @@ -71,6 +77,7 @@ export interface IGitService { status(cwd: string, pathFilter?: ReadonlySet): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; + findWorkTree(cwd: string): Promise; } export const IGitService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 613ed7b8a..f2c6c651d 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -3,9 +3,8 @@ * * Parses `git status --porcelain=v1 --branch`, `git diff --numstat`, and * `gh pr view --json` output into the protocol `FsGitStatusResponse` shape. - * No IO, no DI — plain functions so they can be unit-tested directly. Moved - * from `session/sessionFs/fsGit.ts` (originally ported from v1 - * `services/fs/fsGit.ts`). + * No IO, no DI — plain functions so they can be unit-tested directly. + * Originally ported from v1 `services/fs/fsGit.ts`. */ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git'; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 7f6017237..52938adb1 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -2,7 +2,9 @@ * `git` domain (L1) — `IGitService` implementation. * * Runs `git status` / `git diff` (and `gh pr view`) against a repository on - * the local disk. Process spawning goes through the App-scope + * the local disk, and discovers the enclosing git work tree of a directory + * (`findWorkTree`, delegating to the pure `workTree` probe). Process spawning + * goes through the App-scope * `IHostProcessService` from `os/interface`, and the single path-existence * probe in `diff` goes through `IHostFileSystem`; no Node platform API is * imported directly. Bound at App scope — it owns no Session dependency, so @@ -19,6 +21,7 @@ import { IHostProcessService } from '#/os/interface/hostProcess'; import { IGitService } from './git'; import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers'; +import { findGitWorkTree, type GitWorkTree } from './workTree'; const DIFF_MAX_BYTES = 1_048_576; @@ -123,6 +126,10 @@ export class GitService implements IGitService { }; } + findWorkTree(cwd: string): Promise { + return findGitWorkTree(this.fs, cwd); + } + private async readPullRequest(cwd: string): Promise { const cached = this.pullRequestCache.get(cwd); const now = Date.now(); diff --git a/packages/agent-core-v2/src/app/git/workTree.ts b/packages/agent-core-v2/src/app/git/workTree.ts new file mode 100644 index 000000000..527ac7a5a --- /dev/null +++ b/packages/agent-core-v2/src/app/git/workTree.ts @@ -0,0 +1,75 @@ +/** + * `git` domain (L1) — git work-tree discovery. + * + * Walks up from a directory to find the enclosing git work tree: the nearest + * ancestor containing a `.git` entry, either a directory (plain repository) + * or a file holding a `gitdir:` pointer (linked worktree / submodule) whose + * target is resolved into `controlDirPath`. Entries that are neither — or + * files without a parseable pointer — do not count and the walk continues. + * All filesystem access goes through the os `IHostFileSystem` and paths are + * pathe-normalized (Windows-aware, forward slashes). Pure functions — the + * single work-tree probe behind `IGitService.findWorkTree` and the direct + * consumers in `profile` / `mcp`. + */ + +import { dirname, isAbsolute, join, normalize } from 'pathe'; + +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +export interface GitWorkTree { + /** Ancestor directory that contains the `.git` entry. */ + readonly root: string; + /** Path of the `.git` entry itself (`/.git`). */ + readonly dotGitPath: string; + /** + * The repository's real git control directory — `dotGitPath` for a plain + * repository, the resolved `gitdir:` target for a linked worktree / + * submodule. + */ + readonly controlDirPath: string; +} + +export async function findGitWorkTree( + fs: IHostFileSystem, + cwd: string, +): Promise { + if (cwd.length === 0 || !isAbsolute(cwd)) return null; + + let current = normalize(cwd); + while (true) { + const dotGitPath = join(current, '.git'); + const controlDirPath = await probeGitControlDir(fs, dotGitPath, current); + if (controlDirPath !== null) return { root: current, dotGitPath, controlDirPath }; + + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function probeGitControlDir( + fs: IHostFileSystem, + dotGitPath: string, + markerParent: string, +): Promise { + try { + const stat = await fs.stat(dotGitPath); + if (stat.isDirectory) return dotGitPath; + if (!stat.isFile) return null; + + const content = await fs.readText(dotGitPath); + return parseGitDirPointer(content, markerParent) ?? null; + } catch { + return null; + } +} + +function parseGitDirPointer(content: string, markerParent: string): string | undefined { + const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content; + const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim(); + if (line === undefined || !line.startsWith('gitdir:')) return undefined; + + const rawPath = line.slice('gitdir:'.length).trim(); + if (rawPath.length === 0) return undefined; + return normalize(isAbsolute(rawPath) ? rawPath : join(markerParent, rawPath)); +} diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts index 82bfd2263..1ec2fecec 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts @@ -3,8 +3,8 @@ * * Defines the `IHostFolderBrowser` used by the program side (TUI / server) to * let the user browse the real local filesystem when choosing a workspace - * folder. Distinct from the Session-side `sessionFs`, which is sandboxed and may - * be remote. App-scoped. + * folder. Distinct from the Workspace-side `workspaceFs`, which is sandboxed and + * may be remote. App-scoped. * * The wire shapes (`FsBrowseResponse` / `FsHomeResponse`) are defined here as * zod schemas so the `/api/v1` and `/api/v2` transports share one contract. diff --git a/packages/agent-core-v2/src/agent/mcp/configSection.ts b/packages/agent-core-v2/src/app/mcpConfig/configSection.ts similarity index 89% rename from packages/agent-core-v2/src/agent/mcp/configSection.ts rename to packages/agent-core-v2/src/app/mcpConfig/configSection.ts index 82a624b7d..ee68ba630 100644 --- a/packages/agent-core-v2/src/agent/mcp/configSection.ts +++ b/packages/agent-core-v2/src/app/mcpConfig/configSection.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — registers MCP timeout preferences into `config`. + * `mcpConfig` domain (L5) — registers MCP timeout preferences into `config`. * * Owns the global MCP startup and tool-call timeout preferences, including * their environment bindings and persistence guard. Registered into `config` @@ -10,7 +10,7 @@ import { z } from 'zod'; import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from './config-schema'; +import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from '#/mcpCore/config-schema'; export const MCP_SECTION = 'mcp'; diff --git a/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts new file mode 100644 index 000000000..f347e3928 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts @@ -0,0 +1,80 @@ +/** + * `mcpConfig` domain (L5) — `IMcpOAuthStore`, the App-scope persistence + * adapter for MCP OAuth credentials. + * + * Implements the `mcp` domain's `McpOAuthStore` port over the `persistence` + * access-pattern store (`IAtomicDocumentStore`) under the `credentials/mcp` + * scope (`/credentials/mcp/-*.json`). One App-scope instance is + * shared by every workspace handler's `McpOAuthService`, replacing the + * per-handler stores they used to build ad hoc; the on-disk layout is + * unchanged, so credentials stay shared with out-of-engine readers (the + * node-sdk global OAuth surface). The {@link createMcpOAuthStore} factory + * remains exported for those out-of-engine callers, which run an + * `McpOAuthService` outside the DI container. + * + * Read semantics: missing or corrupt JSON resolves to `undefined` (never + * throws). The provider treats `undefined` as "not stored". + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { McpOAuthStore } from '#/mcpCore/oauth/store'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; + +export interface IMcpOAuthStore extends McpOAuthStore { + readonly _serviceBrand: undefined; +} + +export const IMcpOAuthStore: ServiceIdentifier = + createDecorator('mcpOAuthStore'); + +const CREDENTIALS_SCOPE = 'credentials/mcp'; + +export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore { + return { + async read(key: string): Promise { + try { + return await docs.get(CREDENTIALS_SCOPE, key); + } catch { + return undefined; + } + }, + write(key, data) { + return docs.set(CREDENTIALS_SCOPE, key, data); + }, + remove(key) { + return docs.delete(CREDENTIALS_SCOPE, key); + }, + }; +} + +export class McpOAuthStoreAdapter implements IMcpOAuthStore { + declare readonly _serviceBrand: undefined; + + private readonly delegate: McpOAuthStore; + + constructor(@IAtomicDocumentStore docs: IAtomicDocumentStore) { + this.delegate = createMcpOAuthStore(docs); + } + + read(key: string): Promise { + return this.delegate.read(key); + } + + write(key: string, data: unknown): Promise { + return this.delegate.write(key, data); + } + + remove(key: string): Promise { + return this.delegate.remove(key); + } +} + +registerScopedService( + LifecycleScope.App, + IMcpOAuthStore, + McpOAuthStoreAdapter, + ScopeActivation.OnDemand, + 'mcpConfig', +); diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 3fb648cfc..1ff08d60a 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -41,7 +41,11 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; +import { + IInstantiationService, + type ServicesAccessor, +} from '#/_base/di/instantiation'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; @@ -53,11 +57,22 @@ const MAX_PAGE_SIZE = 100; export class MessageLegacyService implements IMessageLegacyService { declare readonly _serviceBrand: undefined; + /** + * Stable accessor over the App container (same wrapper `Scope.accessor` + * uses — one `invokeFunction` per resolution, never a stashed transient + * accessor), feeding the `sessionLookup` composition helpers. + */ + private readonly services: ServicesAccessor; + constructor( - @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, + @IInstantiationService instantiation: IInstantiationService, @ISessionIndex private readonly index: ISessionIndex, @IAppendLogStore private readonly appendLog: IAppendLogStore, - ) {} + ) { + this.services = { + get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), + }; + } async list(sessionId: string, query: MessageListQuery): Promise> { const all = await this.loadMessages(sessionId); @@ -107,7 +122,8 @@ export class MessageLegacyService implements IMessageLegacyService { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } - const session = await this.lifecycle.resume(sessionId); + // The shared index → handler → session-lifecycle composition. + const session = await resumeSessionById(this.services, sessionId); if (session === undefined) return []; const agent = await ensureMainAgent(session); diff --git a/packages/agent-core-v2/src/app/plugin/commands.ts b/packages/agent-core-v2/src/app/plugin/commands.ts index 6983aadbd..7dbe9c99f 100644 --- a/packages/agent-core-v2/src/app/plugin/commands.ts +++ b/packages/agent-core-v2/src/app/plugin/commands.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { parseFrontmatter } from '#/app/skillCatalog/parser'; +import { parseFrontmatter } from '#/_base/text/frontmatter'; import type { PluginCommandDef } from './types'; diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index d5772737a..38f1eacc2 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -11,8 +11,8 @@ import path from 'node:path'; import { Error2, PluginErrors } from '#/errors'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { PluginAgentRoot } from './types'; import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery'; import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; @@ -314,8 +314,8 @@ export class PluginManager { return roots; } - pluginAgentRoots(): readonly AgentFileRoot[] { - const roots: AgentFileRoot[] = []; + pluginAgentRoots(): readonly PluginAgentRoot[] { + const roots: PluginAgentRoot[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const dir of record.manifest.agents ?? []) { diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts index ac3b7ddc0..3a3a7bae0 100644 --- a/packages/agent-core-v2/src/app/plugin/manifest.ts +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -2,7 +2,7 @@ import { readdir, readFile, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { HookDefSchema, type HookDefConfig } from '#/agent/externalHooks/configSection'; -import { McpServerConfigSchema, type McpServerConfig } from '#/agent/mcp/config-schema'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; import { PLUGIN_NAME_REGEX, diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts index f9d1162bb..7e300e06a 100644 --- a/packages/agent-core-v2/src/app/plugin/plugin.ts +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -10,13 +10,13 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; import type { SkillRoot } from '#/app/skillCatalog/types'; import type { EnabledPluginSessionStart, EnabledPluginSystemPrompt, + PluginAgentRoot, PluginCommandDef, PluginInfo, PluginSummary, @@ -60,7 +60,7 @@ export interface IPluginService { listPluginCommands(): Promise; checkUpdates(): Promise; pluginSkillRoots(): Promise; - pluginAgentRoots(): Promise; + pluginAgentRoots(): Promise; enabledSessionStarts(): Promise; enabledSystemPrompts(): Promise; enabledMcpServers(): Promise>; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 83fe5d1bc..827282ab2 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -20,8 +20,8 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProviderService } from '#/kosong/provider/provider'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { PluginAgentRoot } from './types'; import type { SkillRoot } from '#/app/skillCatalog/types'; import { PluginManager } from './manager'; @@ -159,7 +159,7 @@ export class PluginService extends Disposable implements IPluginService { return this.runConsumptionRead([], async () => this.manager.pluginSkillRoots()); } - pluginAgentRoots(): Promise { + pluginAgentRoots(): Promise { return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots()); } diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts index 414f8d3a5..4c66f047c 100644 --- a/packages/agent-core-v2/src/app/plugin/types.ts +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -1,5 +1,5 @@ import type { HookDefConfig } from '#/agent/externalHooks/configSection'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; @@ -17,6 +17,17 @@ export interface PluginSessionStart { readonly skill: string; } +/** + * A plugin-contributed agent-definition root directory (`manifest.agents` + * entries). Structurally compatible with the workspace agent-profile loader's + * `AgentFileRoot` (`source: 'plugin'`) so the loader can feed them straight + * into discovery without the plugin domain importing it. + */ +export interface PluginAgentRoot { + readonly path: string; + readonly source: 'plugin'; +} + export interface PluginInterface { readonly displayName?: string; readonly shortDescription?: string; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 7f97631ba..7024d1ef1 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -1,21 +1,29 @@ /** * `sessionExport` domain (L6) — `ISessionExportService` implementation. * - * Coordinates live session flushing through `sessionLifecycle`, derives session - * paths from `bootstrap`, reads persisted summaries through `sessionIndex`, and - * packages diagnostic files through the local zip writer. Bound at App scope. + * Coordinates live session flushing through the live handler registry + * (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), derives + * session paths from the handler-chain addressing, reads persisted summaries + * through `sessionIndex`, and packages diagnostic files through the local + * zip writer. Bound at App scope. */ import { join, resolve } from 'pathe'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { ISessionScopeHandle } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { resolveGlobalLogPath } from '#/_base/log/logConfig'; import { IWireService } from '#/wire/wire'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; +import { + sessionDirOf, + workspacePersistenceScope, +} from '#/workspace/workspaceHandler/addressing'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -47,7 +55,7 @@ export class SessionExportService implements ISessionExportService { constructor( @IBootstrapService private readonly bootstrap: IBootstrapService, @ISessionIndex private readonly index: ISessionIndex, - @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, + @IWorkspaceLifecycleService private readonly workspaceLifecycle: IWorkspaceLifecycleService, @IWorkspaceService private readonly workspaces: IWorkspaceService, @ILogService private readonly log: ILogService, ) {} @@ -98,14 +106,18 @@ export class SessionExportService implements ISessionExportService { private async flushLiveSession(summary: SessionSummary): Promise { const workspace = await this.workspaces.get(summary.workspaceId); - const sessionDir = this.bootstrap.sessionDir(summary.workspaceId, summary.id); + const sessionDir = sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ); let exportSummary: ExportSessionDirectorySummary = { id: summary.id, title: summary.title, workspaceDir: workspace?.root, sessionDir, }; - const handle = this.lifecycle.get(summary.id); + const handle = this.liveSession(summary.id); if (handle === undefined) { return exportSummary; } @@ -137,6 +149,14 @@ export class SessionExportService implements ISessionExportService { return exportSummary; } + private liveSession(sessionId: string): ISessionScopeHandle | undefined { + for (const handler of this.workspaceLifecycle.handlers.list()) { + const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + if (handle !== undefined) return handle; + } + return undefined; + } + private async warnIfFails( message: string, operation: () => Promise, diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 2c196b6b9..743e85717 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,7 +5,7 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * Writes (create / archive) live in `sessionLifecycle` / `session`; the index + * Writes (create / archive) live in `workspaceHandler` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). */ diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 3886beff1..555969ded 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -5,12 +5,12 @@ * metadata merge, and the cross-domain `agent_config` patch), * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` * (`goal`) on top of the native v2 services - * (`ISessionLifecycleService`, `IAgentProfileService`, …). + * (`IWorkspaceHandlerService`, `IAgentProfileService`, …). * * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the * `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately * NOT wrapped here: the edge route calls the native services directly — - * `ISessionLifecycleService.fork` / `archive` / `createChild`, + * `IWorkspaceHandlerService.fork` / `archive` / `createChild`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`, * `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` — because * none of them carries v1-only projection worth centralizing beyond what the diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 312f866ca..c49b75479 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -2,13 +2,15 @@ * `sessionLegacy` domain — `ISessionLegacyService` implementation. * * Stateless App-scope dispatcher: each method resolves the target session (and - * its main agent) per call, delegates to the native v2 services, and projects + * its main agent) per call through the shared `sessionLookup` composition + * (`sessionIndex` → `workspaceLifecycle.handlerFor` → the handler's + * `IWorkspaceHandlerService`), delegates to the native v2 services, and projects * the result into the v1 wire shape. Only `updateProfile` (the cross-domain * `agent_config` patch), `status` (the best-effort status rollup), and `goal` * (the current-goal read) live here; * the `:undo`, `fork`-as-child, and child-listing actions were pushed down into * the native services (`IAgentPromptService.undo`, - * `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and + * `IWorkspaceHandlerService.createChild`, `ISessionIndex.list({ childOf })`) and * are called by the edge route directly. No business logic is duplicated here; * the real work stays in the native services. */ @@ -19,10 +21,15 @@ import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessi import { type IAgentScopeHandle, + type ISessionScopeHandle, LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; +import { + IInstantiationService, + type ServicesAccessor, +} from '#/_base/di/instantiation'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; @@ -31,8 +38,11 @@ import { IAgentPlanService } from '#/agent/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IConfigService } from '#/app/config/config'; +import { + getLiveSessionById, + resumeSessionById, +} from '#/app/workspaceLifecycle/sessionLookup'; import { IModelCatalog } from '#/kosong/model/catalog'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -45,13 +55,29 @@ import { ISessionLegacyService, type SessionWireFields } from './sessionLegacy'; export class SessionLegacyService implements ISessionLegacyService { declare readonly _serviceBrand: undefined; - constructor(@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService) {} + /** + * Stable accessor over the App container (same wrapper `Scope.accessor` + * uses — one `invokeFunction` per resolution, never a stashed transient + * accessor), feeding the `sessionLookup` composition helpers. + */ + private readonly services: ServicesAccessor; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.services = { + get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), + }; + } + + /** The shared index → handler → session-lifecycle composition (no App facade). */ + private resume(sessionId: string): Promise { + return resumeSessionById(this.services, sessionId); + } async updateProfile( sessionId: string, body: UpdateSessionProfileRequest, ): Promise { - const session = await this.lifecycle.resume(sessionId); + const session = await this.resume(sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -141,7 +167,7 @@ export class SessionLegacyService implements ISessionLegacyService { } private async resolveMainAgent(sessionId: string): Promise { - const session = await this.lifecycle.resume(sessionId); + const session = await this.resume(sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -194,7 +220,7 @@ export class SessionLegacyService implements ISessionLegacyService { * level — a cold session is simply not busy. */ private readBusy(sessionId: string): boolean { - const handle = this.lifecycle.get(sessionId); + const handle = getLiveSessionById(this.services, sessionId); if (handle === undefined) return false; for (const agent of handle.accessor.get(IAgentLifecycleService).list()) { const state = agent.accessor.get(IAgentActivityView).state(); diff --git a/packages/agent-core-v2/src/app/skillCatalog/parser.ts b/packages/agent-core-v2/src/app/skillCatalog/parser.ts index be7924c78..af6870230 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/parser.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/parser.ts @@ -4,26 +4,17 @@ * Parses a SKILL.md (frontmatter + body) into a `SkillDefinition` and extracts * flowchart blocks. Pure functions with no IO: callers (the catalog Store * backends) read bytes however they like and pass the decoded text in. Keeping - * parsing here lets the Store layer stay filesystem-agnostic. + * parsing here lets the Store layer stay filesystem-agnostic. The frontmatter + * split itself is shared text infrastructure (`_base/text/frontmatter`). */ import path from 'pathe'; -import { load as loadYaml } from 'js-yaml'; +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -export class FrontmatterError extends Error { - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'FrontmatterError'; - if (cause !== undefined) { - Object.defineProperty(this, 'cause', { value: cause, configurable: true }); - } - } -} - export class SkillParseError extends Error { readonly reason?: unknown; @@ -56,11 +47,6 @@ export interface ParseSkillTextOptions extends ParseSkillOptions { readonly text: string; } -export interface ParsedFrontmatter { - readonly data: unknown; - readonly body: string; -} - const FENCE = '---'; const METADATA_ALIASES: Readonly> = { 'when-to-use': 'whenToUse', @@ -69,31 +55,6 @@ const METADATA_ALIASES: Readonly> = { disable_model_invocation: 'disableModelInvocation', }; -export function parseFrontmatter(text: string): ParsedFrontmatter { - const lines = text.split(/\r?\n/); - if (lines[0]?.trim() !== FENCE) { - return { data: null, body: text }; - } - - const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); - if (close === -1) { - throw new FrontmatterError('Missing closing frontmatter fence'); - } - - const yamlText = lines.slice(1, close).join('\n').trim(); - const body = lines.slice(close + 1).join('\n'); - if (yamlText === '') { - return { data: {}, body }; - } - - try { - return { data: loadYaml(yamlText) ?? {}, body }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new FrontmatterError(message, error); - } -} - export function parseSkillText(options: ParseSkillTextOptions): SkillDefinition { const isDirectorySkill = path.basename(options.skillMdPath) === 'SKILL.md'; if (isDirectorySkill && options.text.split(/\r?\n/, 1)[0]?.trim() !== FENCE) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index c0a1c7633..7d1022f98 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -48,6 +48,29 @@ export async function projectRoots( return roots; } +/** + * The project root and the raw project skill-root candidates + * (`.kimi-code/skills` and `.agents/skills` under it) regardless of + * existence — the watch plan for project-skill refreshes, since + * `projectRoots` filters to directories that already exist. + */ +export interface ProjectSkillRootCandidates { + readonly projectRoot: string; + readonly candidates: readonly string[]; +} + +export async function projectSkillRootCandidates( + workDir: string, +): Promise { + const projectRoot = await findProjectRoot(workDir); + return { + projectRoot, + candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) => + path.join(projectRoot, dir), + ), + }; +} + export async function configuredRoots( dirs: readonly string[], workDir: string, diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts index 9db2f74d4..77c4cf730 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts @@ -30,6 +30,13 @@ export const SKILL_SOURCE_PRIORITY = { workspace: 30, } as const; +/** + * The well-known `ISkillSource.id` of the plugin source, shared by every + * scope tier the source is bound at (today: Workspace scope) and by change + * listeners matching plugin-sourced refreshes. + */ +export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; + export interface ISkillSource { readonly _serviceBrand: undefined; readonly id: string; diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts new file mode 100644 index 000000000..3249ecd2f --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts @@ -0,0 +1,132 @@ +/** + * `workspaceLifecycle` domain (L6) — pure session-lookup helpers over the handler chain. + * + * The explicit `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` → + * handler `IWorkspaceHandlerService` composition, shared by every caller + * that addresses a session by id from outside the Workspace scope (edge + * routes, in-process SDKs). These are plain functions over a STABLE + * accessor (a `Scope` / scope-handle `accessor`, never a transient + * `invokeFunction` one) — they are not an App-scope session lifecycle + * facade: the live registry and every lifecycle method stay on the + * handler's own service. Own no scoped state. + */ + +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import type { ISessionScopeHandle, IWorkspaceScopeHandle } from '#/_base/di/scope'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, isError2 } from '#/errors'; +import { + IWorkspaceHandlerService, + type ResumeSessionOptions, +} from '#/workspace/workspaceHandler/workspaceHandler'; + +import { IWorkspaceLifecycleService } from './workspaceLifecycle'; + +/** + * The handler of one session, resolved through the persisted index and + * materialized on demand. Returns `undefined` when the session is unknown + * (or its workspace cannot be resolved to a root — the old resume path's + * `undefined` outcome). + */ +export async function handlerForSession( + accessor: ServicesAccessor, + sessionId: string, +): Promise { + const summary = await accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + try { + return await accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: summary.workspaceId, root: summary.cwd }); + } catch (error) { + if (isError2(error) && error.code === ErrorCodes.WORKSPACE_NOT_FOUND) return undefined; + throw error; + } +} + +/** + * Resume a session by id through its handler; `undefined` when unknown. + * Failures in the routing leg (index read, handler materialization) report + * the same `session_load_failed` telemetry the handler reports for its own + * resume failures — the old App-scope resume covered both from one call. + */ +export async function resumeSessionById( + accessor: ServicesAccessor, + sessionId: string, + opts?: ResumeSessionOptions, +): Promise { + let handler: IWorkspaceScopeHandle | undefined; + try { + handler = await handlerForSession(accessor, sessionId); + } catch (error) { + accessor + .get(ITelemetryService) + .withContext({ sessionId }) + .track2('session_load_failed', { + reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', + }); + throw error; + } + if (handler === undefined) return undefined; + return handler.accessor.get(IWorkspaceHandlerService).resume(sessionId, opts); +} + +/** The live handler holding `sessionId`, without materializing anything. */ +export function liveHandlerForSession( + accessor: ServicesAccessor, + sessionId: string, +): IWorkspaceScopeHandle | undefined { + for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { + if (handler.accessor.get(IWorkspaceHandlerService).get(sessionId) !== undefined) { + return handler; + } + } + return undefined; +} + +/** The live session handle for `sessionId`, without materializing anything. */ +export function getLiveSessionById( + accessor: ServicesAccessor, + sessionId: string, +): ISessionScopeHandle | undefined { + return liveHandlerForSession(accessor, sessionId)?.accessor + .get(IWorkspaceHandlerService) + .get(sessionId); +} + +/** Close a live session through its handler; a no-op when the session is not live. */ +export async function closeSessionById( + accessor: ServicesAccessor, + sessionId: string, +): Promise { + const handler = liveHandlerForSession(accessor, sessionId); + if (handler === undefined) return; + await handler.accessor.get(IWorkspaceHandlerService).close(sessionId); +} + +/** + * Subscribe `follow` to the `IWorkspaceHandlerService` of every handler — + * present and future (handlers are never closed, so subscriptions stay + * valid for the App lifetime). For App-scope observers of per-handler + * events (e.g. `onDidCloseSession`). + */ +export function followWorkspaceHandlers( + accessor: ServicesAccessor, + follow: (service: IWorkspaceHandlerService) => IDisposable, +): IDisposable { + const lifecycle = accessor.get(IWorkspaceLifecycleService); + const store = new DisposableStore(); + for (const handler of lifecycle.handlers.list()) { + store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + } + store.add( + lifecycle.onDidMaterializeHandler((handler) => { + if (!store.isDisposed) { + store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + } + }), + ); + return store; +} diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts new file mode 100644 index 000000000..01ab16195 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts @@ -0,0 +1,53 @@ +/** + * `workspaceLifecycle` domain (L6) — workspace handler lifecycle contract. + * + * Defines the `IWorkspaceLifecycleService`, the App-scope owner of the live + * workspace handler registry: one `IWorkspaceScopeHandle` per workspaceId, + * materialized on demand through `handlerFor` (create-or-get with an + * in-flight join, so concurrent sessions of one workspace never duplicate a + * handler) and never closed afterwards — handlers die with the App scope. + * A handler is addressed by `workspaceId` or by `root` (folded through the + * `workspace` catalog, which is also the local runtime's metadata source); + * the remote-runtime keying (`osBackendId` × `persistenceBackendId`) rides + * on the handler's `workspaceContext` seed as an internal abstraction only. + * Read side: `handlers.list()` and `sessions.list(workspaceId)`, plus + * `onDidMaterializeHandler` for App-scope observers that must follow every + * handler's per-handler services. There is deliberately NO App-scope + * session lifecycle entry point — session create/resume/fork lives on the + * handler's `IWorkspaceHandlerService`; callers compose `sessionIndex` → + * `handlerFor` → handler (see `sessionLookup`). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IWorkspaceScopeHandle } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +/** + * Handler address: a `workspaceId` (optionally with a `root` hint used when + * the catalog record is missing and the handler must be materialized), or a + * bare `root` resolved through the `workspace` catalog. + */ +export type WorkspaceRef = + | { readonly workspaceId: string; readonly root?: string } + | { readonly root: string }; + +export interface WorkspaceHandlerRegistry { + list(): readonly IWorkspaceScopeHandle[]; +} + +export interface WorkspaceSessionRegistry { + /** Live session ids of one handler (empty when the handler is not materialized). */ + list(workspaceId: string): readonly string[]; +} + +export interface IWorkspaceLifecycleService { + readonly _serviceBrand: undefined; + + readonly onDidMaterializeHandler: Event; + handlerFor(ref: WorkspaceRef): Promise; + readonly handlers: WorkspaceHandlerRegistry; + readonly sessions: WorkspaceSessionRegistry; +} + +export const IWorkspaceLifecycleService: ServiceIdentifier = + createDecorator('workspaceLifecycleService'); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts new file mode 100644 index 000000000..744fb61f9 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -0,0 +1,161 @@ +/** + * `workspaceLifecycle` domain (L6) — `IWorkspaceLifecycleService` implementation. + * + * Holds the live handler registry (`Map`) + * and materializes handlers through the DI scope tree, seeding each + * Workspace scope with its `workspaceContext` (identity, catalog metadata, + * the `sessions/{wd_id}` persistence scope, and the local runtime keying + * pair). `handlerFor` is create-or-get with an in-flight join keyed by + * workspaceId, so concurrent materializations of one workspace — by id or + * by any alias spelling of its root — converge on a single handler; a + * failed materialization only drops its own in-flight entry and never + * disturbs live handlers. Materialization refreshes the catalog record + * through `workspace.createOrTouch` (the same write the old per-session + * materialization performed, now once per handler) — only local workspaces + * are ever written to `workspaces.json`. Handlers are never closed: they + * die with the App scope's disposal cascade. Bound at App scope. + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { + createScopedChildHandle, + type IWorkspaceScopeHandle, + LifecycleScope, + ScopeActivation, + registerScopedService, +} from '#/_base/di/scope'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { + LOCAL_OS_BACKEND_ID, + LOCAL_PERSISTENCE_BACKEND_ID, + workspaceContextSeed, + type IWorkspaceContext, +} from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { workspacePersistenceScope } from '#/workspace/workspaceHandler/addressing'; + +import { + IWorkspaceLifecycleService, + type WorkspaceHandlerRegistry, + type WorkspaceRef, + type WorkspaceSessionRegistry, +} from './workspaceLifecycle'; + +export class WorkspaceLifecycleService extends Disposable implements IWorkspaceLifecycleService { + declare readonly _serviceBrand: undefined; + private readonly live = new Map(); + /** In-flight materializations, keyed by workspaceId. Concurrent + * `handlerFor` calls for the same workspace join the in-flight one + * (never a duplicate handler). */ + private readonly materializing = new Map>(); + private readonly _onDidMaterializeHandler = this._register( + new Emitter(), + ); + readonly onDidMaterializeHandler: Event = + this._onDidMaterializeHandler.event; + + readonly handlers: WorkspaceHandlerRegistry = { + list: () => [...this.live.values()], + }; + + readonly sessions: WorkspaceSessionRegistry = { + list: (workspaceId: string) => { + const handler = this.live.get(workspaceId); + if (handler === undefined) return []; + return handler.accessor + .get(IWorkspaceHandlerService) + .list() + .map((session) => session.id); + }, + }; + + constructor( + @IInstantiationService private readonly instantiation: IInstantiationService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IWorkspaceService private readonly workspaces: IWorkspaceService, + @IHostEnvironment private readonly hostEnv: IHostEnvironment, + ) { + super(); + } + + async handlerFor(ref: WorkspaceRef): Promise { + if ('workspaceId' in ref) { + const existing = this.live.get(ref.workspaceId); + if (existing !== undefined) return existing; + const root = ref.root ?? (await this.workspaces.get(ref.workspaceId))?.root; + if (root === undefined) { + throw new Error2( + ErrorCodes.WORKSPACE_NOT_FOUND, + `workspace ${ref.workspaceId} does not exist`, + ); + } + return this.joinMaterialization(ref.workspaceId, root); + } + // Resolve the id through the catalog first (registry folding may reuse an + // id minted for another spelling) so alias spellings join one + // materialization; the resolved record doubles as the seed metadata, so + // the catalog is written exactly once per handler. + const workspace = await this.workspaces.createOrTouch(ref.root); + const existing = this.live.get(workspace.id); + if (existing !== undefined) return existing; + return this.joinMaterialization(workspace.id, workspace.root, workspace); + } + + private joinMaterialization( + workspaceId: string, + root: string, + known?: Workspace, + ): Promise { + const inflight = this.materializing.get(workspaceId); + if (inflight !== undefined) return inflight; + const promise = this.doMaterialize(workspaceId, root, known).finally(() => + this.materializing.delete(workspaceId), + ); + this.materializing.set(workspaceId, promise); + return promise; + } + + private async doMaterialize( + workspaceId: string, + root: string, + known?: Workspace, + ): Promise { + // Refresh the catalog record (re-creating it when the caller addressed + // the handler by id + root hint and the record is gone) and seed the + // canonical root the registry resolved. + const workspace = known ?? (await this.workspaces.createOrTouch(root)); + const ctx: IWorkspaceContext = { + _serviceBrand: undefined, + workspaceId, + cwd: workspace.root, + source: 'local', + meta: workspace, + persistenceScope: workspacePersistenceScope(this.bootstrap.scope('sessions'), workspaceId), + osBackendId: LOCAL_OS_BACKEND_ID, + persistenceBackendId: LOCAL_PERSISTENCE_BACKEND_ID, + }; + await this.hostEnv.ready; + const handle = createScopedChildHandle( + this.instantiation, + LifecycleScope.Workspace, + workspaceId, + { extra: workspaceContextSeed(ctx) }, + ) as IWorkspaceScopeHandle; + this.live.set(workspaceId, handle); + this._onDidMaterializeHandler.fire(handle); + return handle; + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', +); diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a39073527..cdf271d7d 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -14,11 +14,11 @@ import { TaskErrors } from '#/agent/task/errors'; import { ProtocolErrors } from '#/kosong/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; import { FileErrors } from '#/app/file/fileService'; -import { FsErrors } from '#/session/sessionFs/errors'; +import { FsErrors } from '#/workspace/workspaceFs/errors'; import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; import { GoalErrors } from '#/agent/goal/errors'; import { LoopErrors } from '#/agent/loop/errors'; -import { McpErrors } from '#/agent/mcp/errors'; +import { McpErrors } from '#/mcpCore/errors'; import { MessageLegacyErrors } from '#/app/messageLegacy/errors'; import { ModelCatalogErrors } from '#/kosong/model/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; @@ -47,11 +47,11 @@ export { TaskErrors } from '#/agent/task/errors'; export { ProtocolErrors } from '#/kosong/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; export { FileErrors } from '#/app/file/fileService'; -export { FsErrors } from '#/session/sessionFs/errors'; +export { FsErrors } from '#/workspace/workspaceFs/errors'; export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; export { GoalErrors } from '#/agent/goal/errors'; export { LoopErrors } from '#/agent/loop/errors'; -export { McpErrors } from '#/agent/mcp/errors'; +export { McpErrors } from '#/mcpCore/errors'; export { MessageLegacyErrors } from '#/app/messageLegacy/errors'; export { ModelCatalogErrors } from '#/kosong/model/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 517b460e5..88391356e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -65,6 +65,7 @@ import '#/app/event/eventService'; export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/_base/state/stateRegistry'; +export * from '#/_base/contribution/registry'; export * from '#/app/state/state'; import '#/app/state/stateService'; export * from '#/session/state/sessionState'; @@ -156,7 +157,11 @@ import '#/kosong/provider/bases/openai/index'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; export * from '#/app/agentProfileCatalog/agentProfileCatalog'; -export * from '#/app/agentProfileCatalog/agentProfileCatalogService'; +export * from '#/app/agentProfileCatalog/agentProfileContribution'; +export * from '#/app/agentProfileCatalog/agentProfileRegistry'; +export * from '#/app/agentProfileCatalog/agentProfileRegistryService'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; export * from '#/app/agentProfileCatalog/profile-shared'; export * from '#/app/agentProfileCatalog/promptPrefix'; export { @@ -164,16 +169,14 @@ export { getAgentProfileContributions, _clearAgentProfileContributionsForTests, } from '#/app/agentProfileCatalog/contribution'; -export * from '#/app/agentFileCatalog/types'; -export * from '#/app/agentFileCatalog/paths'; -export * from '#/app/agentFileCatalog/agentRoots'; -export * from '#/app/agentFileCatalog/agentFile'; -export * from '#/app/agentFileCatalog/agentFileDiscovery'; -export * from '#/app/agentFileCatalog/agentProfileFromFile'; -export * from '#/app/agentFileCatalog/configSection'; -export * from '#/app/agentFileCatalog/agentProfileSource'; -export * from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -export * from '#/app/agentFileCatalog/userFileAgentSource'; +export * from '#/workspace/workspaceAgentProfileLoader/configSection'; +// Public agent-file primitives consumed out-of-package (the v2 print CLI's +// `--agent-file` path); everything else under `internal/` stays domain-private. +export { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +export { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; +export * from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; export * from '#/app/hostIdentity/hostIdentity'; export * from '#/app/plugin/types'; export * from '#/app/plugin/commands'; @@ -185,6 +188,8 @@ export * from '#/app/plugin/archive'; export * from '#/app/plugin/manager'; export * from '#/app/plugin/plugin'; export * from '#/app/plugin/pluginService'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; export type { SkillSource } from '#/app/skillCatalog/types'; export * from '#/agent/tools/skill/skill'; @@ -205,17 +210,29 @@ export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; export * from '#/session/sessionSkillCatalog/skillCatalog'; +export * from '#/session/sessionSkillCatalog/skillCatalogData'; export * from '#/session/sessionSkillCatalog/skillCatalogService'; -export * from '#/session/sessionSkillCatalog/extraFileSkillSource'; -export * from '#/session/sessionSkillCatalog/explicitFileSkillSource'; -export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; -export * from '#/session/sessionSkillCatalog/pluginSkillSource'; export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +export * from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; -export * from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource'; -export * from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; -export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; -export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; +export * from '#/session/sessionInstructions/instructionsProvider'; +export * from '#/session/workspaceInfo/workspaceInfo'; +export * from '#/workspace/workspaceDirs/workspaceDirs'; +export * from '#/workspace/workspaceDirs/workspaceDirsService'; +export * from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +export * from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +export * from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +export * from '#/workspace/workspaceInstructions/workspaceInstructions'; +export * from '#/workspace/workspaceInstructions/workspaceInstructionsService'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; export * from '#/agent/toolApproval/toolApproval'; @@ -314,8 +331,18 @@ import '#/session/agentLifecycle/profile/profiles'; export * from '#/session/agentLifecycle/agentLifecycle'; export * from '#/session/agentLifecycle/agentLifecycleService'; export * from '#/session/agentLifecycle/mainAgent'; -export * from '#/session/mcp/sessionMcp'; -export * from '#/session/mcp/sessionMcpService'; +export * from '#/session/mcp/sessionMcpHandle'; +import '#/app/mcpConfig/configSection'; +export { + MCP_SECTION, + McpSectionSchema, + type McpSection, +} from '#/app/mcpConfig/configSection'; +export * from '#/app/mcpConfig/oauthStore'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +export * from '#/workspace/workspaceMcp/workspaceMcp'; +export * from '#/workspace/workspaceMcp/workspaceMcpService'; export * from '#/session/subagent/subagent'; export * from '#/session/subagent/subagentService'; import '#/session/subagent/flag'; @@ -327,8 +354,14 @@ export * from '#/session/subagent/mirrorAgentRun'; import '#/session/subagent/configSection'; export * from '#/agent/tools/agent/agent'; import '#/agent/tools/agent/agentTool'; -export * from '#/app/sessionLifecycle/sessionLifecycle'; -export * from '#/app/sessionLifecycle/sessionLifecycleService'; +export * from '#/app/workspaceLifecycle/workspaceLifecycle'; +export * from '#/app/workspaceLifecycle/workspaceLifecycleService'; +export * from '#/app/workspaceLifecycle/sessionLookup'; +export * from '#/workspace/workspaceContext/workspaceContext'; +export * from '#/workspace/workspaceHandler/workspaceHandler'; +export * from '#/workspace/workspaceHandler/workspaceHandlerService'; +export * from '#/workspace/workspaceHandler/addressing'; +export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; @@ -356,8 +389,6 @@ export * from '#/app/gateway/gatewayService'; export * from '#/session/workspaceContext/workspaceContext'; export * from '#/session/workspaceContext/workspaceContextService'; -export * from '#/session/workspaceCommand/workspaceCommand'; -export * from '#/session/workspaceCommand/workspaceCommandService'; export * from '#/app/projectLocalConfig/projectLocalConfig'; export * from '#/app/workspace/workspace'; export * from '#/app/workspace/workspaceService'; @@ -373,14 +404,23 @@ export * from '#/app/bashParser/bashParser'; import '#/app/bashParser/bashParserService'; export * from '#/session/process/processRunner'; export * from '#/session/process/processRunnerService'; -export * from '#/session/sessionFs/errors'; -export * from '#/session/sessionFs/fs'; -export * from '#/session/sessionFs/fsService'; -export * from '#/session/sessionFs/fsWatch'; -export * from '#/session/sessionFs/fsWatchService'; -export * from '#/session/sessionFs/gitContext'; -export * from '#/session/sessionFs/rgLocator'; -export * from '#/session/sessionFs/runRg'; +export * from '#/workspace/workspaceProcess/workspaceProcessRunnerService'; +export * from '#/workspace/workspaceFs/errors'; +export * from '#/workspace/workspaceFs/fs'; +export * from '#/workspace/workspaceFs/fsService'; +export * from '#/workspace/workspaceFs/fsWatch'; +export * from '#/workspace/workspaceFs/fsWatchService'; +export * from '#/session/agentLifecycle/profile/gitContext'; +export * from '#/workspace/workspaceFs/rgLocator'; +export * from '#/workspace/workspaceFs/runRg'; +export * from '#/workspace/workspaceGit/workspaceGit'; +export * from '#/workspace/workspaceGit/workspaceGitService'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService'; +export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +export * from '#/workspace/workspaceTrust/workspaceTrust'; +export * from '#/workspace/workspaceTrust/workspaceTrustService'; export * from '#/app/hostFolderBrowser/hostFolderBrowser'; export * from '#/app/hostFolderBrowser/hostFolderBrowserService'; export * from '#/persistence/interface/storage'; @@ -494,7 +534,7 @@ export * from '#/agent/loop/loopContinuationService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; -export * from '#/agent/mcp/config-schema'; +export * from '#/mcpCore/config-schema'; export * from '#/agent/media/mediaTools'; export * from '#/agent/media/mediaToolsRegistrar'; export * from '#/agent/media/registerMediaTools'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-http.ts b/packages/agent-core-v2/src/mcpCore/client-http.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-http.ts rename to packages/agent-core-v2/src/mcpCore/client-http.ts index bdfa0a40f..342b40ad9 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-http.ts +++ b/packages/agent-core-v2/src/mcpCore/client-http.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — Streamable HTTP transport MCP client. + */ + import type { McpServerHttpConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-remote.ts b/packages/agent-core-v2/src/mcpCore/client-remote.ts similarity index 91% rename from packages/agent-core-v2/src/agent/mcp/client-remote.ts rename to packages/agent-core-v2/src/mcpCore/client-remote.ts index 8ca292abd..eef8f4e01 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-remote.ts +++ b/packages/agent-core-v2/src/mcpCore/client-remote.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — remote (HTTP/SSE) server config guards and request-header builders. + */ + import type { McpRemoteServerConfig, McpServerConfig } from './config-schema'; import { ErrorCodes, Error2 } from '#/errors'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-shared.ts b/packages/agent-core-v2/src/mcpCore/client-shared.ts similarity index 97% rename from packages/agent-core-v2/src/agent/mcp/client-shared.ts rename to packages/agent-core-v2/src/mcpCore/client-shared.ts index ce3e1db66..c8a6687e5 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-shared.ts +++ b/packages/agent-core-v2/src/mcpCore/client-shared.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — shared MCP client helpers — request options, liveness probes, result conversion. + */ + import { getCoreVersion } from '#/_base/version'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-sse.ts b/packages/agent-core-v2/src/mcpCore/client-sse.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-sse.ts rename to packages/agent-core-v2/src/mcpCore/client-sse.ts index 5da36c0a7..700d9c518 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-sse.ts +++ b/packages/agent-core-v2/src/mcpCore/client-sse.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — SSE transport MCP client. + */ + import type { McpServerSseConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-stdio.ts rename to packages/agent-core-v2/src/mcpCore/client-stdio.ts index d5d5ebab4..c3e84229e 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — stdio transport MCP client. + */ + import { ErrorCodes, Error2 } from '#/errors'; import type { McpServerStdioConfig } from './config-schema'; import { proxyEnvForChild, reconcileChildNoProxy } from '#/_base/utils/proxy'; diff --git a/packages/agent-core-v2/src/agent/mcp/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts similarity index 97% rename from packages/agent-core-v2/src/agent/mcp/config-schema.ts rename to packages/agent-core-v2/src/mcpCore/config-schema.ts index db31f929f..e5026d509 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — MCP server configuration schemas. + * `mcpCore` domain (L2) — MCP server configuration schemas. * * Owns the `McpServerConfig` schema and its transport variants. These describe * the shape of MCP server entries as they appear in configuration (whether in diff --git a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts similarity index 96% rename from packages/agent-core-v2/src/agent/mcp/connection-manager.ts rename to packages/agent-core-v2/src/mcpCore/connection-manager.ts index 5aaa4ec89..aa5dbc89c 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -1,13 +1,14 @@ /** - * `mcp` domain (L5) — `McpConnectionManager`, the per-session MCP server - * connection orchestrator. + * `mcpCore` domain (L2) — `McpConnectionManager`, the workspace-shared MCP + * server connection orchestrator. * * Owns the configured MCP servers and their runtime clients: connects * (stdio / SSE / HTTP), discovers and registers tools, attaches the OAuth - * provider through `mcp/oauth` when tokens are present, flips failing - * servers into `needs-auth` on 401, and reconnects after authentication. - * Applies per-server settings over session defaults and emits status changes - * to subscribers. Constructed by `SessionMcpService`. + * provider when tokens are present, flips failing servers into `needs-auth` + * on 401, and reconnects after authentication. Applies per-server settings + * over the configured defaults and emits status changes to subscribers. + * Constructed by `WorkspaceMcpService` and shared by every session of the + * workspace handler. */ import { ErrorCodes, Error2 } from '#/errors'; @@ -21,7 +22,7 @@ import { isRemoteMcpConfig } from './client-remote'; import { SseMcpClient } from './client-sse'; import type { UnexpectedCloseReason } from './client-shared'; import { StdioMcpClient } from './client-stdio'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types'; export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; @@ -121,6 +122,11 @@ export class McpConnectionManager { return entry !== undefined ? toPublicEntry(entry) : undefined; } + /** The config an entry was last connected with, for idempotence checks. */ + configOf(name: string): McpServerConfig | undefined { + return this.entries.get(name)?.config; + } + resolved( name: string, ): diff --git a/packages/agent-core-v2/src/agent/mcp/errors.ts b/packages/agent-core-v2/src/mcpCore/errors.ts similarity index 90% rename from packages/agent-core-v2/src/agent/mcp/errors.ts rename to packages/agent-core-v2/src/mcpCore/errors.ts index f568f57a3..d5b20d184 100644 --- a/packages/agent-core-v2/src/agent/mcp/errors.ts +++ b/packages/agent-core-v2/src/mcpCore/errors.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain error codes. + * `mcpCore` domain (L2) — error codes. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts rename to packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts index 070b1546e..cde9faf00 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts @@ -1,5 +1,5 @@ /** - * One-shot localhost OAuth callback listener. + * `mcpCore` domain (L2) — one-shot localhost OAuth callback listener. * * `startCallbackServer()` binds 127.0.0.1 on a random free port and returns a * handle exposing the resulting `redirect_uri` and an awaitable diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/provider.ts b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/provider.ts rename to packages/agent-core-v2/src/mcpCore/oauth/provider.ts index c92606280..23c87e50f 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/provider.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — `McpOAuthClientProvider`, the `OAuthClientProvider` + * `mcpCore` domain (L2) — `McpOAuthClientProvider`, the `OAuthClientProvider` * backed by the MCP OAuth credential store (`McpOAuthStore` over * `IAtomicDocumentStore`). * diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/service.ts rename to packages/agent-core-v2/src/mcpCore/oauth/service.ts index 1ee5e3c4d..39b11ee60 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — `McpOAuthService`, the per-process OAuth orchestrator + * `mcpCore` domain (L2) — `McpOAuthService`, the per-process OAuth orchestrator * for MCP HTTP servers. * * Owns one {@link McpOAuthClientProvider} per server/resource and mediates the diff --git a/packages/agent-core-v2/src/mcpCore/oauth/store.ts b/packages/agent-core-v2/src/mcpCore/oauth/store.ts new file mode 100644 index 000000000..d2c9b56e4 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/oauth/store.ts @@ -0,0 +1,47 @@ +/** + * `mcpCore` domain (L2) — MCP OAuth credential store port and key addressing. + * + * Defines the {@link McpOAuthStore} port the OAuth provider/service read and + * write credentials through, plus the store-key scheme: one logical record + * per `(serverName, serverUrl)` identity, addressed by {@link mcpOAuthStoreKey} + * (sanitized name prefix + a digest of name and canonicalized URL). The + * persistence implementation lives in the `mcpConfig` wrapper domain + * (`IMcpOAuthStore`, backed by `IAtomicDocumentStore` under the + * `credentials/mcp` scope); this file holds no IO. + */ + +import { createHash } from 'node:crypto'; + +import { basename } from 'pathe'; + +export function sanitizeStoreKey(name: string): string { + const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); + if (safe.length === 0 || safe.startsWith('.')) { + throw new Error(`Invalid MCP OAuth store key: "${name}"`); + } + return safe; +} + +export function canonicalMcpOAuthResource(serverUrl: string | URL): string { + const url = new URL(serverUrl); + url.hash = ''; + return url.toString(); +} + +export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string { + const safeName = sanitizeStoreKey(serverName); + const resource = canonicalMcpOAuthResource(serverUrl); + const digest = createHash('sha256') + .update(serverName) + .update('\0') + .update(resource) + .digest('hex') + .slice(0, 24); + return `${safeName}-${digest}`; +} + +export interface McpOAuthStore { + read(key: string): Promise; + write(key: string, data: unknown): Promise; + remove(key: string): Promise; +} diff --git a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts b/packages/agent-core-v2/src/mcpCore/tool-naming.ts similarity index 90% rename from packages/agent-core-v2/src/agent/mcp/tool-naming.ts rename to packages/agent-core-v2/src/mcpCore/tool-naming.ts index 111fa9cbd..c4053febd 100644 --- a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts +++ b/packages/agent-core-v2/src/mcpCore/tool-naming.ts @@ -1,7 +1,10 @@ +/** + * `mcpCore` domain (L2) — qualified `mcp__server__tool` name sanitizing and hashing. + */ + const MCP_NAME_PREFIX = 'mcp__'; const MCP_NAME_SEPARATOR = '__'; -export { isMcpToolName } from '#/tool/toolContract'; const MAX_QUALIFIED_LENGTH = 64; export function sanitizeMcpNamePart(part: string): string { diff --git a/packages/agent-core-v2/src/agent/mcp/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts similarity index 94% rename from packages/agent-core-v2/src/agent/mcp/types.ts rename to packages/agent-core-v2/src/mcpCore/types.ts index ff783c6b6..2527963d4 100644 --- a/packages/agent-core-v2/src/agent/mcp/types.ts +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -1,5 +1,6 @@ /** - * MCP protocol types and the minimal client contract `ToolManager` consumes. + * `mcpCore` domain (L2) — MCP protocol types and the minimal client contract + * `ToolManager` consumes. * * Lives in its own file (rather than `toolset.ts`) because the agent-side * tool-runtime layer is `ExecutableTool`, not the legacy `Toolset` interface. diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts index 596945539..2a8127376 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts @@ -7,7 +7,7 @@ * predicate. Mode-specific argument building and output parsing stay in the * tools themselves. * - * Ported from `session/sessionFs/runRg` onto the os tools: the subprocess now + * Ported from `workspace/workspaceFs/runRg` onto the os tools: the subprocess now * goes through `IHostProcessService.spawn` instead of the session * `ISessionProcessRunner.exec`. */ diff --git a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts index c9856592b..ec024f33b 100644 --- a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts +++ b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts @@ -11,7 +11,8 @@ * * Async initialization: probing (`ready`) discovers the shell path — on * Windows this may run `git.exe --exec-path`. The composition root - * (`sessionLifecycle`) `await`s `ready` before creating any Session scope, so + * (`workspaceLifecycle` / `workspaceHandler`) `await`s `ready` before creating + * any Session scope, so * every Session/Agent-scope consumer reads the sync fields safely. * * App-scoped — one shared instance for the whole process. diff --git a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts index 890ce98c6..02830826e 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -4,7 +4,7 @@ * Defines the `IHostFsWatchService`, a thin primitive over the host OS file * watcher. It reports raw create/modify/delete events under an absolute path * and knows nothing about sessions, connections, workspaces or wire frames. - * App-scoped — one shared instance. Higher layers (e.g. `sessionFsWatch`) + * App-scoped — one shared instance. Higher layers (e.g. `workspaceFsWatch`) * subscribe, confine events to a workspace, debounce/coalesce and re-expose * them as domain events. */ diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index 571f6ba29..45556adda 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -5,9 +5,10 @@ * registry (`get` / `list` / `remove`), and the lifecycle events — plus the * session-wide fan-outs only the live registry can reach * (`broadcastPermissionMode`). Driving turns on an agent — and the hook/event - * surface those runs announce — lives in the `subagent` domain; session-level - * MCP lives in the `sessionMcp` domain. Session-scoped — one instance per - * session. + * surface those runs announce — lives in the `subagent` domain; the shared MCP + * connection manager lives in the Workspace-scope `workspaceMcp` domain and + * reaches agents through the seeded `ISessionMcpHandle`. Session-scoped — one + * instance per session. * * Invariants: * - The registry is flat: agents have no nesting. There is no parent/child or diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index df5e66a16..5d904289b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -16,14 +16,15 @@ * with the conventional `MAIN_AGENT_ID`, and `fork` requires its source to * exist. Caller-facing orchestration (record mirroring, hooks, telemetry, * prompt prefixes) lives with the callers — driving turns on an agent is the - * `subagent` domain (`ISessionSubagentService`); the session's shared MCP - * subsystem is the `sessionMcp` domain (`ISessionMcpService`), which this - * service awaits during creation. + * `subagent` domain (`ISessionSubagentService`); the workspace's shared MCP + * manager arrives through the seeded `ISessionMcpHandle`, whose initial + * connect this service awaits during creation. */ import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; +import { join } from 'pathe'; import { createScopedChildHandle, type IAgentScopeHandle, @@ -40,7 +41,7 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -85,7 +86,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, - @ISessionMcpService private readonly sessionMcp: ISessionMcpService, + @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, @ISessionInteractionService private readonly interaction: ISessionInteractionService, @ITelemetryService private readonly telemetry: ITelemetryService, ) { @@ -151,25 +152,20 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } private async doCreate(agentId: string, opts: CreateAgentOptions): Promise { - const mcpReady = this.sessionMcp.ensureMcpReady(); - const agentHomedir = this.bootstrap.agentHomedir( - this.ctx.workspaceId, - this.ctx.sessionId, - agentId, - ); - const agentScope = this.bootstrap.agentScope( - this.ctx.workspaceId, - this.ctx.sessionId, - agentId, - ); + const mcpReady = this.mcpHandle.ready; + // Agent persistence addressing derives from the session's scope string + // (itself handler-bound): `{sessionScope}/agents/{agentId}` — identical + // to the layout the pre-Workspace engine wrote. + const agentScope = this.ctx.scope(`agents/${agentId}`); + const agentHomedir = join(this.bootstrap.homeDir, agentScope); const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Agent, agentId, // Seed identity facts and the telemetry view. Every other agent-scope // service either derives its configuration from `IAgentScopeContext` - // (wire, blob) or resolves it through the scope tree (the - // session's shared MCP manager via `ISessionMcpService`). + // (wire, blob) or resolves it through the scope tree (the workspace's + // shared MCP manager via the seeded `ISessionMcpHandle`). { extra: [ [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], @@ -244,13 +240,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle profile: override.profile, model: override.model ?? sourceData.modelAlias, thinking: override?.thinking ?? sourceData.thinkingLevel, - cwd: override?.cwd ?? sourceData.cwd, }); } else { childProfile.applyBindingSnapshot(sourceData); if (override?.model !== undefined) await childProfile.setModel(override.model); if (override?.thinking !== undefined) childProfile.setThinking(override.thinking); - if (override?.cwd !== undefined) childProfile.update({ cwd: override.cwd }); } const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index 4d81f6f56..17e4c5567 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -12,7 +12,7 @@ * and returns an already-created main agent as-is — so concurrent * bootstrappers always receive the same, fully-bootstrapped handle (activity * lane `idle`). Session services activated when their scope is created (cron, - * external hooks) are materialized by `sessionLifecycle.materializeSession`; + * external hooks) are materialized by `workspaceHandler.materializeSession`; * the default permission posture is * applied in `bindBootstrap`. * diff --git a/packages/agent-core-v2/src/session/sessionFs/gitContext.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts similarity index 100% rename from packages/agent-core-v2/src/session/sessionFs/gitContext.ts rename to packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 43d5befca..c2287149b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -12,7 +12,7 @@ * list before `AgentProfileCatalogService` constructs. */ -import { collectGitContext } from '#/session/sessionFs/gitContext'; +import { collectGitContext } from './gitContext'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import { renderSystemPrompt, diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index 5413a89c5..d39a72fc1 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -1,6 +1,6 @@ /** * `session` domain error codes — shared across the session layer - * (`sessionLifecycle` / `sessionLegacy` / `messageLegacy`). + * (`workspaceHandler` / `sessionLegacy` / `messageLegacy`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index 4acbc33b3..f31af7adc 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -2,8 +2,10 @@ * `externalHooks` domain (L6) — Session-scope adapter for external hook * commands. * - * Registers with `sessionLifecycle` hook slots to run `SessionStart` and - * `SessionEnd` external commands for the current `sessionContext`, and + * Registers with the per-session `sessionLifecycleHooks` slots (seeded by + * the Workspace-scope `workspaceHandler`, which runs them around + * create/close) to run `SessionStart` and `SessionEnd` external commands + * for the current `sessionContext`, and * observes the requester-side agent-run hook slot (`onWillStartAgentTask`) and * stop event (`onDidStopAgentTask`) hosted on the `subagent` domain's * `ISessionSubagentService` to translate them into the `SubagentStart` / @@ -19,12 +21,14 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; +import type { Hooks } from '#/hooks'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { - ISessionLifecycleService, + ISessionLifecycleHooks, type SessionCloseReason, type SessionCreateSource, -} from '#/app/sessionLifecycle/sessionLifecycle'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { type AgentTaskStartHookContext, type AgentTaskStopHookContext, @@ -43,24 +47,22 @@ export class SessionExternalHooksService constructor( @ISessionContext private readonly context: ISessionContext, - @ISessionLifecycleService lifecycle: ISessionLifecycleService, + @ISessionLifecycleHooks lifecycleHooks: Hooks, @ISessionSubagentService subagents: ISessionSubagentService, @IExternalHooksRunnerService private readonly runner: IExternalHooksRunnerService, ) { super(); this._register( - lifecycle.hooks.onDidCreateSession.register('externalHooks', async (event, next) => { - if (event.sessionId === this.context.sessionId && event.source !== 'fork') { + lifecycleHooks.onDidCreateSession.register('externalHooks', async (event, next) => { + if (event.source !== 'fork') { await this.triggerSessionStart(event.source); } await next(); }), ); this._register( - lifecycle.hooks.onWillCloseSession.register('externalHooks', async (event, next) => { - if (event.sessionId === this.context.sessionId) { - await this.triggerSessionEnd(event.reason); - } + lifecycleHooks.onWillCloseSession.register('externalHooks', async (event, next) => { + await this.triggerSessionEnd(event.reason); await next(); }), ); diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts deleted file mode 100644 index b0ec61cbf..000000000 --- a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * `mcp` domain (L5) — session-scoped MCP subsystem contract. - * - * Defines `ISessionMcpService` for connecting the session's servers and - * exposing their shared connection manager. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { McpConnectionManager } from '#/agent/mcp/connection-manager'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; - -export interface ISessionMcpService { - readonly _serviceBrand: undefined; - - /** - * Resolve the session/plugin MCP config and wait for the initial connection - * attempt to finish. The initial connect waits for `config.ready` so global - * timeout preferences apply deterministically. Per-server failures are - * reflected in MCP status entries rather than rejecting this promise; an - * outright failure is logged. `callerServers` (caller-supplied servers from - * session create) merge into the initial connect between file config and - * plugin servers; the first call wins — the initial load is cached and - * later calls ignore the arg. - */ - ensureMcpReady(callerServers?: Readonly>): Promise; - - /** - * The session's shared connection manager. Built lazily on first call and - * always available, independent of the initial connect's progress; global - * timeout defaults are read from `config` at each (re)connect. - */ - connectionManager(): McpConnectionManager; -} - -export const ISessionMcpService: ServiceIdentifier = - createDecorator('sessionMcpService'); diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts new file mode 100644 index 000000000..1f40ac1a3 --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts @@ -0,0 +1,31 @@ +/** + * `mcp` domain (L5) — seeded MCP shared-handle contract. + * + * Defines `ISessionMcpHandle`, the pure-data injection contract the + * Workspace-scope `workspaceMcp` service hands to every Session scope it + * creates: the handler's one shared `McpConnectionManager` (all sessions of + * the workspace connect through the same manager — no per-session + * connections exist) plus the initial-connect readiness promise. The contract + * carries no IO of its own — connecting, reloading and watching MCP config + * files live on the workspace side. Seeded into the Session scope by + * `workspaceHandler` when the session is materialized; the Agent-scope `mcp` + * mirror resolves it upward through the scope tree. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; + +export interface ISessionMcpHandle { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly connectionManager: McpConnectionManager; +} + +export const ISessionMcpHandle: ServiceIdentifier = + createDecorator('sessionMcpHandle'); + +export function sessionMcpHandleSeed(handle: ISessionMcpHandle): ScopeSeed { + return [[ISessionMcpHandle as ServiceIdentifier, handle]]; +} diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts deleted file mode 100644 index 09bb137e5..000000000 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * `mcp` domain (L5) — `ISessionMcpService` implementation. - * - * Owns the session-wide `McpConnectionManager` (built lazily, shared by every - * agent), resolves the session + caller-supplied + plugin MCP config, drives - * the initial connect (`ensureMcpReady`, cached so session creation and first - * agent creation can both await it), and reports connection telemetry. The - * initial connect waits for `config.ready` so global timeout preferences are - * deterministic; the manager reads them again at each (re)connect. An - * outright initial-load failure is logged (per-server failures are status - * entries). Bound at Session scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { McpConnectionManager } from '#/agent/mcp/connection-manager'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { createMcpOAuthStore } from '#/agent/mcp/oauth/store'; -import { mergeCallerMcpServers, resolveSessionMcpConfig } from '#/agent/mcp/session-config'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { ILogService } from '#/_base/log/log'; -import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { ISessionMcpService } from './sessionMcp'; - -export class SessionMcpService extends Disposable implements ISessionMcpService { - declare readonly _serviceBrand: undefined; - - private mcpManager: McpConnectionManager | undefined; - private mcpInitialLoad: Promise | undefined; - - constructor( - @IBootstrapService private readonly bootstrap: IBootstrapService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IPluginService private readonly plugins: IPluginService, - @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, - @ILogService private readonly log: ILogService, - @ITelemetryService private readonly telemetry: ITelemetryService, - @IConfigService private readonly config: IConfigService, - ) { - super(); - } - - ensureMcpReady(callerServers?: Readonly>): Promise { - if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; - const manager = this.connectionManager(); - const initialLoad = this.initializeMcp(manager, callerServers).catch((error: unknown) => { - this.log.error('mcp initial load failed', { error }); - }); - this.mcpInitialLoad = initialLoad; - return initialLoad; - } - - connectionManager(): McpConnectionManager { - if (this.mcpManager !== undefined) return this.mcpManager; - const oauthService = new McpOAuthService({ - store: createMcpOAuthStore(this.atomicDocs), - }); - const manager = new McpConnectionManager({ - log: this.log, - oauthService, - stdioCwd: this.workspace.workDir, - resolveDefaultTimeouts: () => { - const section = this.config.get(MCP_SECTION); - return { - startupTimeoutMs: section?.startupTimeoutMs, - toolTimeoutMs: section?.toolTimeoutMs, - }; - }, - }); - this.mcpManager = manager; - this._register({ dispose: () => void manager.shutdown() }); - return manager; - } - - private async initializeMcp( - manager: McpConnectionManager, - callerServers?: Readonly>, - ): Promise { - await this.config.ready; - await this.connectMcpServers(manager, callerServers); - } - - private async connectMcpServers( - manager: McpConnectionManager, - callerServers?: Readonly>, - ): Promise { - const [base, pluginServers] = await Promise.all([ - resolveSessionMcpConfig({ cwd: this.workspace.workDir, homeDir: this.bootstrap.homeDir }), - this.plugins.enabledMcpServers(), - ]); - const withCaller = mergeCallerMcpServers(base, callerServers); - const servers = { ...withCaller?.servers, ...pluginServers }; - if (Object.keys(servers).length === 0) return; - await manager.connectAll(servers); - this.trackMcpInitialLoad(manager); - } - - private trackMcpInitialLoad(manager: McpConnectionManager): void { - const entries = manager.list().filter((entry) => entry.status !== 'disabled'); - const totalCount = entries.length; - if (totalCount === 0) return; - - const connectedCount = entries.filter((entry) => entry.status === 'connected').length; - if (connectedCount > 0) { - this.telemetry.track2('mcp_connected', { - server_count: connectedCount, - total_count: totalCount, - }); - } - - const failedCount = entries.filter((entry) => entry.status === 'failed').length; - if (failedCount > 0) { - this.telemetry.track2('mcp_failed', { - failed_count: failedCount, - total_count: totalCount, - }); - } - } -} - -registerScopedService( - LifecycleScope.Session, - ISessionMcpService, - SessionMcpService, - ScopeActivation.OnScopeCreated, - 'sessionMcp', -); diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 793b8fdff..cc2aaafa3 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -1,13 +1,18 @@ /** - * `process` domain (L2) — `ISessionProcessRunner` implementation. + * `process` domain (L2) — the default `ISessionProcessRunner` implementation. * * Resolves the default cwd from the session's `ISessionContext` and delegates * the actual host spawn to the App-scope `IHostProcessService`. A per-call * `options.cwd` wins over the seeded cwd. A per-call `options.env` is overlaid * onto `process.env` and passed as the child's complete env bag (the host * replaces the child env with what we pass); when `options.env` is omitted we - * pass `undefined` so the child inherits `process.env` verbatim. Bound at - * Session scope. + * pass `undefined` so the child inherits `process.env` verbatim. + * + * This Session-scope registration is the DEFAULT for scopes built without a + * workspace handler (test hosts, harness agents). Real sessions get the + * handler-shared Workspace-scope runner (`workspaceProcess`) as a scope seed + * from `workspaceHandler`, which shadows this registration — same pattern as + * the other workspace-capability injection contracts. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts new file mode 100644 index 000000000..5e8db0716 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts @@ -0,0 +1,32 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — seeded workspace-key contract. + * + * Defines `ISessionAgentProfileCatalogSeed`, the pure-data injection contract + * the Workspace handler hands to every Session scope it creates: ONLY the + * handler's `workspaceId`. The Session-scope `ISessionAgentProfileCatalog` + * uses it to pick this workspace's tagged contributions out of the App-scope + * `IAgentProfileRegistry` (global entries plus the ones registered with this + * key) — the catalog reads the registry directly, so no per-workspace merged + * view is seeded anymore. The key travels as a seed (rather than being + * recomputed from the session's workDir) because the handler's id may be + * folded from an alias spelling of the root. Seeded into the Session scope by + * `workspaceHandler` when the session is materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface ISessionAgentProfileCatalogSeed { + readonly _serviceBrand: undefined; + + readonly workspaceKey: string; +} + +export const ISessionAgentProfileCatalogSeed: ServiceIdentifier = + createDecorator('sessionAgentProfileCatalogSeed'); + +export function sessionAgentProfileCatalogSeed( + seed: ISessionAgentProfileCatalogSeed, +): ScopeSeed { + return [[ISessionAgentProfileCatalogSeed as ServiceIdentifier, seed]]; +} diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts deleted file mode 100644 index 138b06c78..000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — explicit `IAgentProfileSource` - * producer. - * - * Loads runtime-selected agent files through `hostFs`, resolving paths through - * `workspace` and `bootstrap`. `${base_prompt}` is backed by the user source's - * effective default profile. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -import { parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; -import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { resolveAgentPath } from '#/app/agentFileCatalog/paths'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IExplicitFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IExplicitFileAgentSource: ServiceIdentifier = - createDecorator('explicitFileAgentSource'); - -export class ExplicitFileAgentSource implements IExplicitFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'explicit'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.explicit; - readonly fatal = true; - - constructor( - @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const files = this.runtimeOptions.explicitFiles ?? []; - const profiles: AgentProfile[] = []; - for (const file of files) { - const filePath = resolveAgentPath(file, this.workspace.workDir, this.bootstrap.osHomeDir); - const text = await this.fs.readText(filePath); - profiles.push( - agentProfileFromFile(parseAgentFileText({ path: filePath, source: 'explicit', text }), (context) => - this.user.getDefaultProfile().systemPrompt(context), - ), - ); - } - return { profiles }; - } -} - -registerScopedService( - LifecycleScope.Session, - IExplicitFileAgentSource, - ExplicitFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts deleted file mode 100644 index c9d6e8a80..000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — extra `IAgentProfileSource` - * producer. - * - * Discovers configured agent profiles through `config`, `workspace`, - * `bootstrap`, and `hostFs`, and reports skipped files through `log`. - * `${base_prompt}` is backed by the user source's effective default profile. - * Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { configuredAgentRoots } from '#/app/agentFileCatalog/agentRoots'; -import { - EXTRA_AGENT_DIRS_SECTION, - type ExtraAgentDirsConfig, -} from '#/app/agentFileCatalog/configSection'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IExtraFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IExtraFileAgentSource: ServiceIdentifier = - createDecorator('extraFileAgentSource'); - -export class ExtraFileAgentSource extends Disposable implements IExtraFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'extra'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; - private readonly onDidChangeEmitter = this._register(new Emitter()); - readonly onDidChange: Event = this.onDidChangeEmitter.event; - - constructor( - @IConfigService private readonly config: IConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) { - super(); - this._register( - this.config.onDidSectionChange((event) => { - if (event.domain === EXTRA_AGENT_DIRS_SECTION) this.onDidChangeEmitter.fire(); - }), - ); - } - - async load(): Promise { - await this.config.ready; - const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; - return profilesFromDiscovery( - await discoverAgentFiles( - this.fs, - await configuredAgentRoots( - this.fs, - dirs, - this.workspace.workDir, - this.bootstrap.osHomeDir, - 'extra', - (message, error) => { - this.log.warn(message, error); - }, - ), - (message) => this.log.warn(message), - ), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IExtraFileAgentSource, - ExtraFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts deleted file mode 100644 index 42af97266..000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — plugin `IAgentProfileSource` - * producer. - * - * Discovers agent profiles contributed by enabled plugins (roots from - * `plugin.pluginAgentRoots()`), contributing them at priority 5 (above - * builtin, below user / extra / project / explicit, so every other file - * source wins name collisions). `${base_prompt}` is backed by the user - * source's effective default profile. Re-emits `plugin.onDidReload` as - * `onDidChange` so the catalog re-pulls plugin agents when plugins reload; - * install / enable / remove mutations deliberately do not refresh the - * session catalog — those take effect on the next explicit reload. Bound at - * Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IPluginService } from '#/app/plugin/plugin'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; - -export interface IPluginAgentProfileSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IPluginAgentProfileSource: ServiceIdentifier = - createDecorator('pluginAgentProfileSource'); - -export class PluginAgentProfileSource implements IPluginAgentProfileSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'plugin'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin; - readonly onDidChange: Event = (listener, thisArg, disposables) => - this.plugins.onDidReload( - () => listener.call(thisArg, undefined as void), - undefined, - disposables, - ); - - constructor( - @IPluginService private readonly plugins: IPluginService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const roots = await this.plugins.pluginAgentRoots(); - return profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => { - this.log.warn(message); - }), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IPluginAgentProfileSource, - PluginAgentProfileSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts deleted file mode 100644 index 9f0740870..000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — project `IAgentProfileSource` - * producer. - * - * Discovers project agent profiles through `workspace` and `hostFs`, and - * reports skipped files through `log`. `${base_prompt}` is backed by the user - * source's effective default profile. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { projectAgentRoots } from '#/app/agentFileCatalog/agentRoots'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IProjectFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IProjectFileAgentSource: ServiceIdentifier = - createDecorator('projectFileAgentSource'); - -export class ProjectFileAgentSource implements IProjectFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'project'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.project; - - constructor( - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const roots = await projectAgentRoots( - this.fs, - this.workspace.workDir, - (message, error) => { - this.log.warn(message, error); - }, - ); - return profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IProjectFileAgentSource, - ProjectFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts index 7be8ea653..41fcdc440 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts @@ -2,19 +2,37 @@ * `sessionAgentProfileCatalog` domain (L3) — Session-scoped merged agent-profile * catalog contract. * - * Defines the merged read view over the builtin (code-contribution) profiles - * and the file-backed sources (user / extra / project / explicit), merged by - * priority — higher-priority file sources win name collisions, while builtin - * names require an explicit override opt-in. Consumers - * (`IAgentProfileService.bind`, the `Agent` tool, the swarm scheduler) resolve - * profiles through this view instead of the App-scope catalog so file-defined - * agents are spawnable and bindable. Bound at Session scope. + * The Catalog of the agent-profile extension point: a read-only projection + * over the App-scope `IAgentProfileRegistry`, scoped to THIS session — it + * merges the global contributions (builtin / plugin / user) with the ones the + * workspace loaders tagged with this session's seeded workspace key + * (workspace / extra / explicit). Name-level dedup happens HERE, in the + * projection: higher-priority sources win name collisions, while builtin + * names require an explicit `override: true` opt-in to be replaced. + * Consumers (`IAgentProfileService.bind`, the `Agent` tool, the swarm + * scheduler) resolve profiles through this view; `inspect(name)` exposes the + * projection's adjudication (winning source, suppressed candidates) for + * debugging surfaces. Bound at Session scope. */ import { createDecorator } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +export interface AgentProfileSuppressedCandidate { + readonly sourceId: string; + readonly priority: number; + readonly reason: 'priority' | 'builtin-override-required'; +} + +export interface AgentProfileInspection { + readonly name: string; + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; + readonly suppressed: readonly AgentProfileSuppressedCandidate[]; +} + export interface ISessionAgentProfileCatalog { readonly _serviceBrand: undefined; @@ -23,6 +41,7 @@ export interface ISessionAgentProfileCatalog { get(name: string): AgentProfile | undefined; getDefault(): AgentProfile; list(): readonly AgentProfile[]; + inspect(name: string): AgentProfileInspection | undefined; load(): Promise; reload(): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index a0612d28a..cb91227d2 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -2,59 +2,46 @@ * `sessionAgentProfileCatalog` domain (L3) — `ISessionAgentProfileCatalog` * implementation. * - * Merges the builtin (code-contribution) App catalog with the file-backed - * sources (plugin / user / extra / project / explicit) by priority, requiring - * an explicit opt-in before a file replaces a same-name builtin, and - * serializing - * refreshes per source the same way `sessionSkillCatalog` does. The merged - * view always contains the builtin profiles (seeded at construction); file - * profiles appear once `ready` resolves. A rejecting `fatal` source (an - * invalid `--agent-file`) propagates into `ready` so `bind()` / `load()` - * awaiters see the error; a rejecting non-fatal source (a transient fs error - * inside a directory source) degrades to a warning and keeps any previously - * loaded contribution, so directory problems never poison the session. - * `ready` tracks the most recent load pass: `reload()` replaces it, so a - * fatal failure does not wedge the catalog once the underlying problem is - * fixed, and `loadAll` merges whatever loaded even when a fatal source - * rejects mid-pass. The swallowed handler on `ready` keeps an un-awaited - * rejection from crashing the process, and event-driven reloads get the - * same warning treatment. The plain-data state (`contributions`, `merged`) - * is registered into `sessionState` (`ISessionStateService`) and - * read/written through it. - * Bound at Session scope. + * Projects the App-scope `IAgentProfileRegistry` into this session's merged + * profile view. The relevant entries are the global ones (builtin) plus the + * ones tagged with the seeded workspace key (user / plugin / extra / + * workspace / explicit); they are re-merged on every registry change (the + * projection is a cheap full recompute — merge, never incremental patching). + * Merge rules, applied per profile name: candidates are collected from every + * relevant entry (deduped within an entry, highest priority first); the first + * candidate wins, except that replacing a same-name `builtin` profile + * requires `override: true` in the frontmatter — a non-override collision is + * warned about and skipped to the next candidate. `ready` resolves + * immediately: loader readiness is the handler's job — it awaits every + * agent-profile loader of the workspace before publishing the session handle, + * so the registry is already populated when this service is constructed, and + * every later change arrives through `onDidChange`. Bound at Session scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { isError2 } from '#/_base/errors/errors'; -import { defineState } from '#/_base/state/stateRegistry'; +import { ILogService } from '#/_base/log/log'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { - DEFAULT_AGENT_PROFILE_NAME, - IAgentProfileCatalogService, - type AgentProfile, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { - AgentProfileContribution, - IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { ISessionStateService } from '#/session/state/sessionState'; + IAgentProfileRegistry, + type AgentProfileRegistration, +} from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; -import { IExplicitFileAgentSource } from './explicitFileAgentSource'; -import { IExtraFileAgentSource } from './extraFileAgentSource'; -import { IPluginAgentProfileSource } from './pluginAgentProfileSource'; -import { IProjectFileAgentSource } from './projectFileAgentSource'; -import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog'; +import { ISessionAgentProfileCatalogSeed } from './agentProfileCatalogSeed'; +import { + ISessionAgentProfileCatalog, + type AgentProfileInspection, + type AgentProfileSuppressedCandidate, +} from './sessionAgentProfileCatalog'; -export const agentProfileCatalogContributionsKey = defineState< - Map ->('sessionAgentProfileCatalog.contributions', () => new Map()); -export const agentProfileCatalogMergedKey = defineState>( - 'sessionAgentProfileCatalog.merged', - () => new Map(), -); +interface ProfileCandidate { + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; +} export class SessionAgentProfileCatalogService extends Disposable @@ -62,61 +49,31 @@ export class SessionAgentProfileCatalogService { declare readonly _serviceBrand: undefined; - private readonly sources: readonly IAgentProfileSource[]; - private readonly sourceLoadTails = new Map>(); - private readyPromise: Promise; + private merged = new Map(); + private inspections = new Map(); private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, - @IPluginAgentProfileSource plugin: IPluginAgentProfileSource, - @IUserFileAgentSource user: IUserFileAgentSource, - @IExtraFileAgentSource extra: IExtraFileAgentSource, - @IProjectFileAgentSource project: IProjectFileAgentSource, - @IExplicitFileAgentSource explicit: IExplicitFileAgentSource, + @IAgentProfileRegistry private readonly registry: IAgentProfileRegistry, + @ISessionAgentProfileCatalogSeed private readonly seed: ISessionAgentProfileCatalogSeed, @ILogService private readonly log: ILogService, ) { super(); - this.states.register(agentProfileCatalogContributionsKey); - this.states.register(agentProfileCatalogMergedKey); - this.sources = [plugin, user, extra, project, explicit].toSorted( - (a, b) => a.priority - b.priority, + this.reproject(); + this._register( + this.registry.onDidChange((change) => { + if (change.workspaceKey !== undefined && change.workspaceKey !== this.seed.workspaceKey) { + return; + } + this.reproject(); + this.onDidChangeEmitter.fire(change.sourceId); + }), ); - for (const s of this.sources) { - if (s.onDidChange) { - this._register( - s.onDidChange(() => { - void this.reloadSource(s.id).catch((error) => { - this.log.warn(`agent profile source "${s.id}" reload failed: ${String(error)}`); - }); - }), - ); - } - } - this.remerge(); - this.readyPromise = this.loadAll(); - void this.readyPromise.catch(() => undefined); - } - - private get contributions(): Map< - string, - { readonly c: AgentProfileContribution; readonly priority: number } - > { - return this.states.get(agentProfileCatalogContributionsKey); - } - - private get merged(): Map { - return this.states.get(agentProfileCatalogMergedKey); - } - - private set merged(value: Map) { - this.states.set(agentProfileCatalogMergedKey, value); } get ready(): Promise { - return this.readyPromise; + return Promise.resolve(); } get(name: string): AgentProfile | undefined { @@ -137,96 +94,110 @@ export class SessionAgentProfileCatalogService return [...this.merged.values()]; } + inspect(name: string): AgentProfileInspection | undefined { + return this.inspections.get(name); + } + async load(): Promise { await this.ready; } async reload(): Promise { - this.readyPromise = this.loadAll(); - void this.readyPromise.catch(() => undefined); - await this.readyPromise; + await this.ready; + this.reproject(); this.onDidChangeEmitter.fire('catalog'); } - private async loadAll(): Promise { - try { - for (const s of this.sources) { - await this.loadSource(s); + private relevantEntries(): AgentProfileRegistration[] { + const key = this.seed.workspaceKey; + return this.registry + .entries() + .filter((e) => e.workspaceKey === undefined || e.workspaceKey === key); + } + + private reproject(): void { + const merged = new Map(); + const inspections = new Map(); + const entries = this.relevantEntries(); + + const builtinEntry = entries.find((e) => e.sourceId === BUILTIN_AGENT_PROFILE_SOURCE_ID); + if (builtinEntry !== undefined) { + for (const profile of builtinEntry.contribution.profiles) { + merged.set(profile.name, profile); + inspections.set(profile.name, { + name: profile.name, + profile, + sourceId: builtinEntry.sourceId, + priority: builtinEntry.priority, + suppressed: [], + }); } - } finally { - this.remerge(); } - } - private async reloadSource(id: string): Promise { - const s = this.sources.find((x) => x.id === id); - if (!s) return; - await this.loadSource(s, true); - } + const fileCandidates = new Map(); + const ordered = entries + .filter((e) => e.sourceId !== BUILTIN_AGENT_PROFILE_SOURCE_ID) + .toSorted((a, b) => b.priority - a.priority); + for (const entry of ordered) { + const entryProfiles = new Map(); + for (const profile of entry.contribution.profiles) { + entryProfiles.set(profile.name, profile); + } + for (const profile of entryProfiles.values()) { + const candidates = fileCandidates.get(profile.name) ?? []; + candidates.push({ + profile, + sourceId: entry.sourceId, + priority: entry.priority, + }); + fileCandidates.set(profile.name, candidates); + } + } - private loadSource(source: IAgentProfileSource, fireChange = false): Promise { - const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); - const current = previous - .catch(() => undefined) - .then(async () => { - let contribution: AgentProfileContribution; - try { - contribution = await source.load(); - } catch (error) { - if (source.fatal) throw error; - const at = isError2(error) ? error.details?.['path'] : undefined; + for (const candidates of fileCandidates.values()) { + const suppressed: AgentProfileSuppressedCandidate[] = []; + let winner = false; + for (const candidate of candidates) { + if (merged.has(candidate.profile.name) && candidate.profile.override !== true) { this.log.warn( - `agent profile source "${source.id}" load failed: ${String(error)}${typeof at === 'string' ? ` [${at}]` : ''}`, - ); - return; - } - this.contributions.set(source.id, { c: contribution, priority: source.priority }); - if (fireChange) { - this.remerge(); - this.onDidChangeEmitter.fire(source.id); - } - }); - this.sourceLoadTails.set(source, current); - const clear = () => { - if (this.sourceLoadTails.get(source) === current) { - this.sourceLoadTails.delete(source); - } - }; - void current.then(clear, clear); - return current; - } - - private remerge(): void { - const m = new Map(); - for (const profile of this.builtin.list()) { - m.set(profile.name, profile); - } - const fileProfiles = new Map(); - const ordered = [...this.contributions.values()].toSorted( - (a, b) => b.priority - a.priority, - ); - for (const { c } of ordered) { - const sourceProfiles = new Map(); - for (const profile of c.profiles) sourceProfiles.set(profile.name, profile); - for (const profile of sourceProfiles.values()) { - const candidates = fileProfiles.get(profile.name) ?? []; - candidates.push(profile); - fileProfiles.set(profile.name, candidates); - } - } - for (const candidates of fileProfiles.values()) { - for (const profile of candidates) { - if (m.has(profile.name) && profile.override !== true) { - this.log.warn( - `agent file profile "${profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + `agent file profile "${candidate.profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, ); + suppressed.push({ + sourceId: candidate.sourceId, + priority: candidate.priority, + reason: 'builtin-override-required', + }); continue; } - m.set(profile.name, profile); + merged.set(candidate.profile.name, candidate.profile); + inspections.set(candidate.profile.name, { + name: candidate.profile.name, + profile: candidate.profile, + sourceId: candidate.sourceId, + priority: candidate.priority, + suppressed: [ + ...suppressed, + ...candidates.slice(candidates.indexOf(candidate) + 1).map((rest) => ({ + sourceId: rest.sourceId, + priority: rest.priority, + reason: 'priority' as const, + })), + ], + }); + winner = true; break; } + if (!winner && suppressed.length > 0) { + const name = candidates[0]?.profile.name; + const existing = name === undefined ? undefined : inspections.get(name); + if (existing !== undefined) { + inspections.set(existing.name, { ...existing, suppressed }); + } + } } - this.merged = m; + + this.merged = merged; + this.inspections = inspections; } } diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts index 316e60c28..2b6704029 100644 --- a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -3,16 +3,15 @@ * * Defines the `ISessionContext` carrying the session's identity, storage * addressing (`sessionId`, `workspaceId`, `sessionDir`, `metaScope`), the - * session's initial working directory (`cwd`), and a `scope(subKey?)` helper - * that returns the session's persistence scope (or a child under it, e.g. - * `scope('agents/main/cron')`). Seeded into the Session scope by - * `sessionLifecycle` when the session is created. + * session's working directory (`cwd`) — frozen at session creation — and a + * `scope(subKey?)` + * helper that returns the session's persistence scope (or a child under it, + * e.g. `scope('agents/main/cron')`). Seeded into the Session scope by + * `workspaceHandler` when the session is created. * - * `cwd` is the working directory frozen at session creation; it is the default - * root the `process` runner spawns in and the seed `workspaceContext` derives - * its mutable `workDir` from. The live, runtime-mutable "current cwd" (changed - * via `chdir`) is owned by `profile` (Agent scope) and `workspaceContext`, not - * here. Pure facts — no store, no IO. Session-scoped. + * `cwd` is the default root the `process` runner spawns in and the seed the + * `workspaceContext` derives its read-only `workDir` from. Pure facts — no + * store, no IO. Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts deleted file mode 100644 index 6e44c4778..000000000 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * `sessionFsWatch` domain (L2) — workspace-confined filesystem change feed. - * - * Defines the `ISessionFsWatchService` that turns the os `IHostFsWatchService` - * raw events into a workspace-relative, debounced, `.gitignore`-aware change - * feed (`FsChangeEvent`) for the session. Callers declare the set of - * workspace-relative paths they care about; events outside that subtree are - * dropped. Session-scoped — the scope itself is the session, so no - * `sessionId` is threaded through. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; - -export type FsChangeKind = 'file' | 'directory' | 'symlink'; - -export type FsChangeAction = 'created' | 'modified' | 'deleted'; - -export interface FsChangeEntry { - path: string; - change: FsChangeAction; - kind: FsChangeKind; - size_delta?: number | undefined; - etag?: string | undefined; -} - -export interface FsChangeEvent { - changes: FsChangeEntry[]; - coalesced_window_ms: number; - truncated?: boolean | undefined; - count?: number | undefined; -} - -export interface ISessionFsWatchService { - readonly _serviceBrand: undefined; - - setWatchedPaths(paths: readonly string[]): void; - - readonly watchedPaths: readonly string[]; - - readonly onDidChangeFiles: Event; -} - -export const ISessionFsWatchService: ServiceIdentifier = - createDecorator('sessionFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index 719230486..142634201 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -3,7 +3,7 @@ * * Runs `/init` against the session's main agent: resolves `main` through * `agentLifecycle`, spawns a `coder` subagent bound to the main agent's own - * model / thinking level / cwd (inheriting the main agent's permission mode), + * model / thinking level (inheriting the main agent's permission mode), * drives one init-brief turn via `subagents.run`, and mirrors the run onto the * main agent's record stream (`emitAgentRunSpawned` + `mirrorAgentRun`) so the * UI shows the nested transcript and the `subagent.*` records fire. Once the @@ -33,6 +33,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; @@ -55,6 +56,7 @@ export class SessionInitService implements ISessionInitService { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionContext private readonly sessionContext: ISessionContext, ) {} cancelInit(): void { @@ -81,7 +83,6 @@ export class SessionInitService implements ISessionInitService { profile: INIT_PROFILE_NAME, model: own.modelAlias, thinking: own.thinkingLevel, - cwd: own.cwd, }, }); child.accessor.get(IAgentPermissionModeService).setMode(permissionMode); @@ -107,7 +108,7 @@ export class SessionInitService implements ISessionInitService { const agentsMd = await loadAgentsMd( { fs: this.fs, homeDir: this.env.homeDir }, - own.cwd, + this.sessionContext.cwd, this.bootstrap.homeDir, ); main.accessor diff --git a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts new file mode 100644 index 000000000..183b6301c --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts @@ -0,0 +1,35 @@ +/** + * `sessionInstructions` domain (L1) — seeded AGENTS.md provider contract. + * + * Defines `ISessionInstructionsProvider`, the pure-data injection contract + * the Workspace-scope `workspaceInstructions` service hands to every Session + * scope it creates: the workspace's current AGENTS.md snapshot (combined + * content plus the oversize/load warning) and the change event fired when a + * watched instruction file invalidates the snapshot. The contract carries no + * IO — loading and watching live on the workspace side; consumers (the + * agent's `profile` service) read the seed and re-read it off `onDidChange`. + * Seeded into the Session scope by `workspaceHandler` when the session is + * materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionInstructionsProvider { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; + readonly onDidChange: Event; +} + +export const ISessionInstructionsProvider: ServiceIdentifier = + createDecorator('sessionInstructionsProvider'); + +export function sessionInstructionsProviderSeed( + provider: ISessionInstructionsProvider, +): ScopeSeed { + return [[ISessionInstructionsProvider as ServiceIdentifier, provider]]; +} diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts new file mode 100644 index 000000000..be9d54975 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts @@ -0,0 +1,40 @@ +/** + * `sessionLifecycleHooks` domain (L1) — per-session lifecycle hook slots. + * + * Defines the `ISessionLifecycleHooks` seed: one ordered hook-slots instance + * per session, created by the Workspace-scope `workspaceHandler` when it + * materializes the session, seeded into the Session scope, and run by the + * handler around the session's create (`onDidCreateSession`) and close + * (`onWillCloseSession`). Session-scope consumers (e.g. `externalHooks`) + * register against this session-domain contract and never see the Workspace + * domain — the §3.5 seed-channel shape. Also owns the shared + * `SessionCreateSource` / `SessionCloseReason` vocabulary both sides speak. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Hooks } from '#/hooks'; + +export type SessionCreateSource = 'startup' | 'resume' | 'fork'; + +export type SessionCloseReason = 'exit'; + +export interface SessionStartHookEvent { + readonly source: SessionCreateSource; +} + +export interface SessionEndHookEvent { + readonly reason: SessionCloseReason; +} + +export type SessionLifecycleHookSlots = { + readonly onDidCreateSession: SessionStartHookEvent; + readonly onWillCloseSession: SessionEndHookEvent; +}; + +export const ISessionLifecycleHooks: ServiceIdentifier> = + createDecorator>('sessionLifecycleHooks'); + +export function sessionLifecycleHooksSeed(hooks: Hooks): ScopeSeed { + return [[ISessionLifecycleHooks as ServiceIdentifier, hooks]]; +} diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts new file mode 100644 index 000000000..c9e353ca3 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts @@ -0,0 +1,33 @@ +/** + * `sessionSkillCatalog` domain (L3) — seeded skill-catalog data contract. + * + * Defines `ISessionSkillCatalogData`, the pure-data injection contract the + * Workspace-scope `workspaceSkillCatalog` hands to every Session scope it + * creates: the workspace's merged skill catalog as a live read view plus the + * source-keyed change event. The contract carries no IO — discovery, merging + * and rescanning all live on the workspace side; the Session-scope + * `ISessionSkillCatalog` business view reads this seed and refreshes itself + * off `onDidChange`. Seeded into the Session scope by `workspaceHandler` when + * the session is materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +import type { SkillCatalog } from '#/app/skillCatalog/types'; + +export interface ISessionSkillCatalogData { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly catalog: SkillCatalog; + readonly onDidChange: Event; +} + +export const ISessionSkillCatalogData: ServiceIdentifier = + createDecorator('sessionSkillCatalogData'); + +export function sessionSkillCatalogDataSeed(data: ISessionSkillCatalogData): ScopeSeed { + return [[ISessionSkillCatalogData as ServiceIdentifier, data]]; +} diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 511ef9457..a6ce05977 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -1,30 +1,31 @@ /** - * `sessionSkillCatalog` domain (L3) — `ISessionSkillCatalog` sink implementation. + * `sessionSkillCatalog` domain (L3) — `ISessionSkillCatalog` sink + * implementation. * - * Merges builtin, user, explicit, extra, workspace, and plugin skill sources - * by priority, serializing refreshes for each source. Exposes the merged read - * view and accepts ad-hoc contributions through `ISkillCatalogSink`. The - * plain-data state (`contributions`, `merged`) is registered into - * `sessionState` (`ISessionStateService`) and read/written through it. Bound - * at Session scope. + * The Session-scope business view over the workspace's merged skill catalog: + * the discovery/merge/rescan work lives on the Workspace-scope + * `workspaceSkillCatalog` and arrives through the seeded + * `ISessionSkillCatalogData` read view — this service never scans the + * filesystem itself. It re-folds the data snapshot on every seeded change + * event (forwarding the source id) and merges session-local ad-hoc + * contributions (`ISkillCatalogSink`) on top by priority. `reload()` no + * longer re-scans: it re-folds the current seed and re-fires `catalog`. + * The plain-data state (`contributions`, `merged`) is registered into + * `sessionState` (`ISessionStateService`) and read/written through it. + * Bound at Session scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; -import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; -import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; +import type { SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; -import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import { ISessionStateService } from '#/session/state/sessionState'; -import { IPluginSkillSource } from './pluginSkillSource'; -import { IExtraFileSkillSource } from './extraFileSkillSource'; -import { IExplicitFileSkillSource } from './explicitFileSkillSource'; import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog'; -import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource'; +import { ISessionSkillCatalogData } from './skillCatalogData'; export const skillCatalogContributionsKey = defineState< Map @@ -40,29 +41,25 @@ export class SessionSkillCatalogService { declare readonly _serviceBrand: undefined; - private readonly sources: readonly ISkillSource[]; - private readonly sourceLoadTails = new Map>(); readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( + @ISessionSkillCatalogData private readonly data: ISessionSkillCatalogData, @ISessionStateService private readonly states: ISessionStateService, - @IBuiltinSkillSource builtin: IBuiltinSkillSource, - @IUserFileSkillSource user: IUserFileSkillSource, - @IExplicitFileSkillSource explicit: IExplicitFileSkillSource, - @IExtraFileSkillSource extra: IExtraFileSkillSource, - @IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource, - @IPluginSkillSource plugin: IPluginSkillSource, ) { super(); this.states.register(skillCatalogContributionsKey); this.states.register(skillCatalogMergedKey); - this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); - for (const s of this.sources) { - if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); - } - this.ready = this.loadAll(); + this._register( + this.data.onDidChange((sourceId) => { + this.remerge(); + this.onDidChangeEmitter.fire(sourceId); + }), + ); + this.remerge(); + this.ready = this.data.ready.then(() => this.remerge()); } private get contributions(): Map< @@ -89,7 +86,8 @@ export class SessionSkillCatalogService } async reload(): Promise { - await this.loadAll(); + await this.ready; + this.remerge(); this.onDidChangeEmitter.fire('catalog'); } @@ -105,41 +103,12 @@ export class SessionSkillCatalogService this.onDidChangeEmitter.fire(id); } - private async loadAll(): Promise { - for (const s of this.sources) { - await this.loadSource(s); - } - this.remerge(); - } - - private async reloadSource(id: string): Promise { - const s = this.sources.find((x) => x.id === id); - if (!s) return; - await this.loadSource(s, true); - } - - private loadSource(source: ISkillSource, fireChange = false): Promise { - const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); - const current = previous.catch(() => undefined).then(async () => { - const contribution = await source.load(); - this.contributions.set(source.id, { c: contribution, priority: source.priority }); - if (fireChange) { - this.remerge(); - this.onDidChangeEmitter.fire(source.id); - } - }); - this.sourceLoadTails.set(source, current); - const clear = () => { - if (this.sourceLoadTails.get(source) === current) { - this.sourceLoadTails.delete(source); - } - }; - void current.then(clear, clear); - return current; - } - private remerge(): void { const m = new InMemorySkillCatalog(); + const base = this.data.catalog; + for (const skill of base.listSkills()) m.register(skill, { replace: true }); + m.addRoots(base.getSkillRoots()); + m.recordSkipped(base.getSkippedByPolicy()); const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); for (const { c } of ordered) { for (const skill of c.skills) m.register(skill, { replace: true }); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts deleted file mode 100644 index e4398a730..000000000 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `sessionSkillCatalog` domain (L3) — workspace `ISkillSource` producer. - * - * Discovers project skills from the session's current `workDir` - * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority - * 30 (above user / extra / plugin / builtin). Bound at Session scope so each session reads - * its own workspace root. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IConfigService } from '#/app/config/config'; -import { - MERGE_ALL_AVAILABLE_SKILLS_SECTION, - type MergeAllAvailableSkillsConfig, -} from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; -import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { projectRoots } from '#/app/skillCatalog/skillRoots'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IWorkspaceFileSkillSource extends ISkillSource { - readonly _serviceBrand: undefined; -} - -export const IWorkspaceFileSkillSource: ServiceIdentifier = - createDecorator('workspaceFileSkillSource'); - -export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFileSkillSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'workspace'; - readonly priority = SKILL_SOURCE_PRIORITY.workspace; - private readonly onDidChangeEmitter = this._register(new Emitter()); - readonly onDidChange: Event = this.onDidChangeEmitter.event; - - constructor( - @ISkillDiscovery private readonly discovery: ISkillDiscovery, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IConfigService private readonly config: IConfigService, - @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - ) { - super(); - this._register( - this.config.onDidSectionChange((event) => { - if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); - }), - ); - } - - async load(): Promise { - if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { - return { skills: [] }; - } - await this.config.ready; - const mergeAllAvailableSkills = - this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); - } -} - -registerScopedService( - LifecycleScope.Session, - IWorkspaceFileSkillSource, - WorkspaceFileSkillSource, - ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts new file mode 100644 index 000000000..d089e7e4f --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -0,0 +1,32 @@ +/** + * `sessionToolPolicyGate` domain (L1) — seeded workspace tool-veto contract. + * + * Defines `ISessionToolPolicyGate`, the pure-data injection contract the + * Workspace-scope `workspaceToolPolicy` hands to every Session scope it + * creates: the workspace's os-level disabled-tool set as a live read view + * plus its change event. The contract carries no IO — capability probing and + * workspace config live on the workspace side; the Agent-scope `toolPolicy` + * and `toolActivation` read this seed and apply the veto (it outranks every + * Agent-side policy layer). Seeded into the Session scope by + * `workspaceHandler` when the session is materialized; a no-op default + * registration keeps scopes built without a handler (tests) resolvable. + * Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionToolPolicyGate { + readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionToolPolicyGate: ServiceIdentifier = + createDecorator('sessionToolPolicyGate'); + +export function sessionToolPolicyGateSeed(gate: ISessionToolPolicyGate): ScopeSeed { + return [[ISessionToolPolicyGate as ServiceIdentifier, gate]]; +} diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts new file mode 100644 index 000000000..37c2daf42 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts @@ -0,0 +1,29 @@ +/** + * `sessionToolPolicyGate` domain (L1) — no-op default `ISessionToolPolicyGate`. + * + * An empty gate (nothing vetoed, never changes) registered at Session scope + * so Session/Agent scopes materialized WITHOUT a workspace handler — test + * hosts, harness agents — still resolve the contract. The handler's seed + * (`sessionToolPolicyGateSeed`) shadows this registration for real sessions, + * the same way every other workspace-resource injection contract works. + */ + +import { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { ISessionToolPolicyGate } from './sessionToolPolicyGate'; + +export class NoopSessionToolPolicyGate implements ISessionToolPolicyGate { + declare readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[] = []; + readonly onDidChange = Event.None as Event; +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicyGate, + NoopSessionToolPolicyGate, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicyGate', +); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 00fa9fc11..9a6525a00 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -164,7 +164,6 @@ export class SessionSwarmService implements ISessionSwarmService { profile: profile.name, model: binding.model, thinking: binding.thinking, - cwd: callerData.cwd, }, labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), }); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/index.ts b/packages/agent-core-v2/src/session/workspaceCommand/index.ts deleted file mode 100644 index 7ca717909..000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * `workspaceCommand` domain barrel — re-exports the workspace-command contract - * (`workspaceCommand`) and its scoped service (`workspaceCommandService`). - * Importing this barrel registers the `ISessionWorkspaceCommandService` - * binding into the scope registry. - */ - -export * from './workspaceCommand'; -export * from './workspaceCommandService'; diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts deleted file mode 100644 index 1463a2d71..000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `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; - 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 deleted file mode 100644 index 7180a0d16..000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * `workspaceCommand` domain (L6) — `ISessionWorkspaceCommandService` implementation. - * - * Coordinates session-level workspace mutations: resolves and persists - * project-local config through `projectLocalConfig`, updates - * `workspaceContext`, and mirrors command output into the main agent through - * `agentLifecycle` and `contextMemory`. The plain-data state - * (`pendingMainInjections`) is registered into `sessionState` - * (`ISessionStateService`) and read/written through it. Bound at Session - * scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { - type AddAdditionalDirInput, - ISessionWorkspaceCommandService, - type WorkspaceAdditionalDirsResult, -} from './workspaceCommand'; - -export const workspaceCommandPendingMainInjectionsKey = defineState( - 'workspaceCommand.pendingMainInjections', - () => [], -); - -export class SessionWorkspaceCommandService - extends Disposable - implements ISessionWorkspaceCommandService -{ - declare readonly _serviceBrand: undefined; - private mutationQueue: Promise = Promise.resolve(); - - constructor( - @ISessionStateService private readonly states: ISessionStateService, - @IProjectLocalConfigService - private readonly localConfig: IProjectLocalConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IAgentLifecycleService private readonly agents: IAgentLifecycleService, - ) { - super(); - this.states.register(workspaceCommandPendingMainInjectionsKey); - this._register( - this.agents.onDidCreate((handle) => { - if (handle.id !== MAIN_AGENT_ID) return; - if (this.pendingMainInjections.length === 0) return; - const pending = this.pendingMainInjections.splice(0); - handle.accessor.get(IAgentContextMemoryService).append(...pending); - }), - ); - } - - private get pendingMainInjections(): ContextMessage[] { - return this.states.get(workspaceCommandPendingMainInjectionsKey); - } - - async addAdditionalDir(input: AddAdditionalDirInput): Promise { - return this.enqueueMutation(() => this.applyAddAdditionalDir(input)); - } - - private async applyAddAdditionalDir( - input: AddAdditionalDirInput, - ): Promise { - const persist = input.persist ?? true; - - if (persist) { - const persisted = await this.localConfig.appendAdditionalDir( - this.workspace.workDir, - input.path, - ); - this.workspace.setAdditionalDirs([ - ...this.workspace.additionalDirs, - ...persisted.additionalDirs, - ]); - this.injectAdditionalDirAdded(input.path, true, persisted.configPath); - return { - projectRoot: persisted.projectRoot, - configPath: persisted.configPath, - additionalDirs: this.workspace.additionalDirs, - persisted: true, - }; - } - - const workspace = await this.localConfig.readAdditionalDirs(this.workspace.workDir); - const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.workDir, [ - input.path, - ]); - this.workspace.setAdditionalDirs([...this.workspace.additionalDirs, ...resolved]); - this.injectAdditionalDirAdded(input.path, false, workspace.configPath); - return { - projectRoot: workspace.projectRoot, - configPath: workspace.configPath, - additionalDirs: this.workspace.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.get(MAIN_AGENT_ID); - if (main !== undefined) { - main.accessor.get(IAgentContextMemoryService).append(message); - return; - } - this.pendingMainInjections.push(message); - } -} - -registerScopedService( - LifecycleScope.Session, - ISessionWorkspaceCommandService, - SessionWorkspaceCommandService, - ScopeActivation.OnScopeCreated, - 'workspaceCommand', -); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts index 0919627a0..c2c03bc1f 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts @@ -3,8 +3,12 @@ * * Defines the `ISessionWorkspaceContext` used by the Agent side to resolve relative * paths against the session work directory and to enforce that file/process - * operations stay within the workspace (plus any additional dirs). Pure - * configuration + boundary — it performs no IO. Session-scoped. + * operations stay within the workspace (plus any additional dirs). The view is + * read-only: `workDir` is fixed at session creation (seeded from + * `ISessionContext`); `additionalDirs` mirrors the handler-shared set (seeded + * as `ISessionWorkspaceInfo`) and refreshes when the workspace-level add-dir + * surface changes it. Pure configuration + boundary — it performs no IO. + * Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -16,13 +20,9 @@ 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; - addAdditionalDir(dir: string): void; - removeAdditionalDir(dir: string): void; } export const ISessionWorkspaceContext: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 4ca6b418d..b260d95c3 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -2,18 +2,24 @@ * `workspaceContext` domain (L1) — `ISessionWorkspaceContext` implementation. * * Holds the session work directory and additional dirs, resolves relative - * paths, and checks whether a path falls within the workspace. The plain-data - * state (`workDir`, `additionalDirs`) is registered into `sessionState` - * (`ISessionStateService`) and read/written through it. Bound at Session - * scope. + * paths, and checks whether a path falls within the workspace. `workDir` is + * frozen at construction from the `sessionContext` seed (`cwd`); the + * additional dirs are a live read view over the handler-shared set, seeded + * as `ISessionWorkspaceInfo` by the workspace handler — the workspace-level + * add-dir surface (`workspaceDirs`) and its `local.toml` watch refresh this + * view through the seed's change event. The plain-data state (`workDir`, + * `additionalDirs`) is registered into `sessionState` + * (`ISessionStateService`) and read through it. Bound at Session scope. */ import { isAbsolute, relative, resolve } from 'node:path'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext'; @@ -23,34 +29,38 @@ export const workspaceContextAdditionalDirsKey = defineState( () => [], ); -export class SessionWorkspaceContextService implements ISessionWorkspaceContext { +export class SessionWorkspaceContextService extends Disposable implements ISessionWorkspaceContext { declare readonly _serviceBrand: undefined; constructor( @ISessionStateService private readonly states: ISessionStateService, @ISessionContext ctx: ISessionContext, + @ISessionWorkspaceInfo workspaceInfo: ISessionWorkspaceInfo, ) { + super(); this.states.register(workspaceContextWorkDirKey); this.states.register(workspaceContextAdditionalDirsKey); - this.setWorkDir(ctx.cwd); + this.states.set(workspaceContextWorkDirKey, resolve(ctx.cwd)); + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + this._register( + workspaceInfo.onDidChange(() => { + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + }), + ); } private get _workDir(): string { return this.states.get(workspaceContextWorkDirKey); } - private set _workDir(value: string) { - this.states.set(workspaceContextWorkDirKey, value); - } - private get _additionalDirs(): string[] { return this.states.get(workspaceContextAdditionalDirsKey); } - private set _additionalDirs(value: string[]) { - this.states.set(workspaceContextAdditionalDirsKey, value); - } - get workDir(): string { return this._workDir; } @@ -59,14 +69,6 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext return this._additionalDirs; } - setWorkDir(workDir: string): void { - 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); } @@ -89,16 +91,6 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext } return target; } - - addAdditionalDir(dir: string): void { - const d = resolve(dir); - if (!this._additionalDirs.includes(d)) this._additionalDirs.push(d); - } - - removeAdditionalDir(dir: string): void { - const d = resolve(dir); - this._additionalDirs = this._additionalDirs.filter((x) => x !== d); - } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts new file mode 100644 index 000000000..209397ce1 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts @@ -0,0 +1,32 @@ +/** + * `workspaceInfo` domain (L1) — seeded workspace-directory data contract. + * + * Defines `ISessionWorkspaceInfo`, the pure-data injection contract the + * Workspace-scope `workspaceDirs` hands to every Session scope it creates: + * the workspace's additional directory set as a live read view plus its + * change event. The contract carries no IO — persistence + * (`.kimi-code/local.toml`), caller-dir merging and file watching all live + * on the workspace side; the Session-scope `workspaceContext` read view + * reads this seed and refreshes itself off `onDidChange`. Seeded into the + * Session scope by `workspaceHandler` when the session is materialized. + * Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionWorkspaceInfo { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionWorkspaceInfo: ServiceIdentifier = + createDecorator('sessionWorkspaceInfo'); + +export function sessionWorkspaceInfoSeed(info: ISessionWorkspaceInfo): ScopeSeed { + return [[ISessionWorkspaceInfo as ServiceIdentifier, info]]; +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts similarity index 92% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts index c1eb781ed..217a05636 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — runtime options for agent-file discovery. + * `workspaceAgentProfileLoader` domain (L3) — runtime options for agent-file discovery. * * Holds process-level runtime overrides: `explicitFiles` mirrors the CLI's * `--agent-file` — individual agent Markdown files loaded as the highest- @@ -47,5 +47,5 @@ registerScopedService( IAgentCatalogRuntimeOptions, AgentCatalogRuntimeOptions, ScopeActivation.OnScopeCreated, - 'agentFileCatalog', + 'workspaceAgentProfileLoader', ); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts similarity index 89% rename from packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts index 34806dcf0..6472566ce 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file config sections. + * `workspaceAgentProfileLoader` domain (L3) — agent-file config sections. * * Registers the top-level config domain `extraAgentDirs`: additional * directories scanned for agent Markdown files. Values stay camelCase in diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts new file mode 100644 index 000000000..e47c34aaf --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts @@ -0,0 +1,23 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExplicitAgentProfileLoader` contract. + * + * The explicit loader of the agent-profile extension point: owns the + * `explicit` contribution in the App-scope `IAgentProfileRegistry` — the + * runtime-selected agent files (`--agent-file`, carried by + * `IAgentCatalogRuntimeOptions`), tagged with this handler's `workspaceId`. + * The loader is `fatal`: an invalid explicit file is an explicit user intent + * that must not be silently dropped, so the rejection propagates into `ready` + * and session materialization fails fast; `reload()` re-arms it once the + * offending file is fixed. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IExplicitAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IExplicitAgentProfileLoader: ServiceIdentifier = + createDecorator('explicitAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts new file mode 100644 index 000000000..97a958051 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts @@ -0,0 +1,80 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExplicitAgentProfileLoader` implementation. + * + * Loads the runtime-selected agent files through `hostFs`, resolving paths + * against the workspace root (`workspaceContext`) and `bootstrap`. + * `${base_prompt}` is backed by the user loader's effective default profile. + * Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IExplicitAgentProfileLoader } from './explicitAgentProfileLoader'; + +export class ExplicitAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IExplicitAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'explicit'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.explicit; + protected override readonly fatal = true; + + constructor( + @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + const files = this.runtimeOptions.explicitFiles ?? []; + const profiles: AgentProfile[] = []; + for (const file of files) { + const filePath = resolveAgentPath(file, this.workspace.cwd, this.bootstrap.osHomeDir); + const text = await this.fs.readText(filePath); + profiles.push( + agentProfileFromFile( + parseAgentFileText({ path: filePath, source: 'explicit', text }), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ), + ); + } + return { profiles }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IExplicitAgentProfileLoader, + ExplicitAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts new file mode 100644 index 000000000..cfb25e646 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts @@ -0,0 +1,22 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExtraAgentProfileLoader` contract. + * + * The extra loader of the agent-profile extension point: owns the `extra` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the configured `extraAgentDirs`, tagged with this handler's + * `workspaceId` (relative configured paths resolve against the workspace + * root, so the contribution is workspace-local even though the config section + * is global). `ready` tracks the most recent discovery pass; `reload()` + * re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IExtraAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IExtraAgentProfileLoader: ServiceIdentifier = + createDecorator('extraAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts new file mode 100644 index 000000000..0715c1737 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts @@ -0,0 +1,98 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExtraAgentProfileLoader` implementation. + * + * Resolves the configured `extraAgentDirs` through `config`, `workspaceContext`, + * `bootstrap`, and `hostFs`, reporting skipped files through `log`. + * `${base_prompt}` is backed by the user loader's effective default profile. + * Reloads when the `extraAgentDirs` config section changes. Bound at + * Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { configuredAgentRoots } from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; +import { + EXTRA_AGENT_DIRS_SECTION, + type ExtraAgentDirsConfig, +} from '#/workspace/workspaceAgentProfileLoader/configSection'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IExtraAgentProfileLoader } from './extraAgentProfileLoader'; + +export class ExtraAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IExtraAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'extra'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; + + constructor( + @IConfigService private readonly config: IConfigService, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === EXTRA_AGENT_DIRS_SECTION) { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "extra" reload failed: ${String(error)}`); + }); + } + }), + ); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + await this.config.ready; + const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; + return profilesFromDiscovery( + await discoverAgentFiles( + this.fs, + await configuredAgentRoots( + this.fs, + dirs, + this.workspace.cwd, + this.bootstrap.osHomeDir, + 'extra', + (message, error) => { + this.log.warn(message, error); + }, + ), + (message) => this.log.warn(message), + ), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IExtraAgentProfileLoader, + ExtraAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts similarity index 97% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index e11b38c9b..cacb0cf68 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file parsing primitives. + * `workspaceAgentProfileLoader` domain (L3) — agent-file parsing primitives. * * Parses a single agent Markdown file (frontmatter + body) into an * `AgentFileDefinition`. Pure functions with no IO: callers read bytes however @@ -12,7 +12,7 @@ * (Claude Code). */ -import { FrontmatterError, parseFrontmatter } from '#/app/skillCatalog/parser'; +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; import type { AgentFileDefinition, AgentFileSource } from './types'; diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts similarity index 98% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts index 4b9a1bc36..e2ec7b7b5 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — filesystem agent-file discovery. + * `workspaceAgentProfileLoader` domain (L3) — filesystem agent-file discovery. * * Discovers and parses agent files through the `hostFs` filesystem boundary. * Invalid files are isolated from the rest of the discovery pass. Failure diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts similarity index 65% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts index 7f8d0affe..741b13acf 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — `AgentFileDefinition` → `AgentProfile` factory. + * `workspaceAgentProfileLoader` domain (L3) — `AgentFileDefinition` → `AgentProfile` factory. * * The file body is a prompt template rendered against the shared variable * table (`systemPromptVars`): `${var}` placeholders substitute live context, @@ -11,15 +11,21 @@ * `IAgentToolPolicyService`; `subagents` passes through as the delegation * allowlist enforced by the `Agent` / `AgentSwarm` tools; `model_preference` * becomes the symbolic default those tools use when spawning the profile. + * `profilesFromDiscovery` packs a whole discovery pass into an + * `AgentProfileContribution`, binding each profile's `${base_prompt}` + * placeholder lazily at render time so it always reflects the effective + * default profile (builtin, or the `SYSTEM.md` override) rather than any + * file-based definition. */ import type { AgentProfile, AgentProfileContext, } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; import { renderPromptTemplate } from '#/app/agentProfileCatalog/profile-shared'; -import type { AgentFileDefinition } from './types'; +import type { AgentFileDefinition, AgentFileDiscoveryResult } from './types'; export function agentProfileFromFile( definition: AgentFileDefinition, @@ -41,3 +47,14 @@ export function agentProfileFromFile( renderPromptTemplate(definition.prompt, context, { skillActive }, basePrompt), }; } + +export function profilesFromDiscovery( + result: AgentFileDiscoveryResult, + basePrompt: (context: AgentProfileContext) => string, +): AgentProfileContribution { + return { + profiles: result.agents.map((definition) => agentProfileFromFile(definition, basePrompt)), + skipped: result.skipped, + scannedRoots: result.scannedRoots, + }; +} diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts new file mode 100644 index 000000000..bd7e74dcd --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts @@ -0,0 +1,83 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `AgentProfileLoaderBase`, the shared + * loader skeleton of the agent-profile extension point. + * + * A loader owns one source id: it loads an `AgentProfileContribution` and + * registers it into the App-scope `IAgentProfileRegistry` under that id + * (workspace-local loaders additionally tag a `workspaceKey`). The first load + * starts when the subclass constructor calls {@link start} — after its own + * fields are set, since `load()` is virtual. `ready` tracks the most recent + * load pass; `reload()` replaces it, so a `fatal` failure does not wedge the + * loader once the underlying problem is fixed. A rejecting `fatal` loader + * (an invalid `--agent-file`) propagates into `ready` so session + * materialization fails fast; a rejecting non-fatal loader degrades to a + * warning and keeps any previously registered contribution, so directory + * problems never poison the registry. Loads are serialized per loader — a + * refresh never overlaps the previous pass — and the swallowed handler on + * `ready` keeps an un-awaited rejection from crashing the process. + */ + +import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { ILogService } from '#/_base/log/log'; +import type { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; + +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; + +export abstract class AgentProfileLoaderBase extends Disposable { + protected abstract readonly sourceId: string; + protected abstract readonly priority: number; + protected readonly fatal: boolean = false; + + private readyPromise: Promise = Promise.resolve(); + private tail: Promise = Promise.resolve(); + private readonly registrationHandle = this._register(new MutableDisposable()); + + constructor( + protected readonly registry: IAgentProfileRegistry, + protected readonly log: ILogService, + ) { + super(); + } + + get ready(): Promise { + return this.readyPromise; + } + + /** Starts the first load; subclass constructors call this after their own fields are set. */ + protected start(): void { + this.readyPromise = this.enqueue(); + void this.readyPromise.catch(() => undefined); + } + + async reload(): Promise { + this.readyPromise = this.enqueue(); + void this.readyPromise.catch(() => undefined); + await this.readyPromise; + } + + protected abstract load(): Promise; + + /** Workspace-local loaders override to tag their registration. */ + protected get workspaceKey(): string | undefined { + return undefined; + } + + private enqueue(): Promise { + const current = this.tail.catch(() => undefined).then(() => this.loadAndRegister()); + this.tail = current; + return current; + } + + private async loadAndRegister(): Promise { + try { + const contribution = await this.load(); + this.registrationHandle.value = this.registry.register(this.sourceId, contribution, { + priority: this.priority, + workspaceKey: this.workspaceKey, + }); + } catch (error) { + if (this.fatal) throw error; + this.log.warn(`agent profile loader "${this.sourceId}" load failed: ${String(error)}`); + } + } +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts similarity index 81% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts index 1bccdc11a..ba230da52 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-root resolution primitives. + * `workspaceAgentProfileLoader` domain (L3) — agent-root resolution primitives. * * Resolves user, project, and configured discovery roots through the `hostFs` * filesystem boundary. Pure path probes; no scoped state. @@ -46,6 +46,31 @@ export async function projectAgentRoots( return roots; } +/** + * The project root and the raw project agent-root candidates + * (`.kimi-code/agents` and `.agents/agents` under it) regardless of + * existence — the watch plan for project-agent refreshes, since + * `projectAgentRoots` filters to directories that already exist. + */ +export interface ProjectAgentRootCandidates { + readonly projectRoot: string; + readonly candidates: readonly string[]; +} + +export async function projectAgentRootCandidates( + fs: IHostFileSystem, + workDir: string, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(fs, workDir, warn); + return { + projectRoot, + candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) => + join(projectRoot, dir), + ), + }; +} + export async function configuredAgentRoots( fs: IHostFileSystem, dirs: readonly string[], diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts similarity index 95% rename from packages/agent-core-v2/src/app/agentFileCatalog/paths.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts index 76b9aa92c..84f77b036 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — shared path primitives for agent-file + * `workspaceAgentProfileLoader` domain (L3) — shared path primitives for agent-file * discovery. * * `~` expansion, base-relative resolution, and `hostFs` type probes used by diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts similarity index 96% rename from packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts index 3d8685e6b..f9ad84d4a 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — `SYSTEM.md` global main-agent prompt override. + * `workspaceAgentProfileLoader` domain (L3) — `SYSTEM.md` global main-agent prompt override. * * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts similarity index 81% rename from packages/agent-core-v2/src/app/agentFileCatalog/types.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts index 9187fd2a3..12cad2b2b 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file model types. + * `workspaceAgentProfileLoader` domain (L3) — agent-file model types. * * Shared types for the agent-file primitives: the parsed single-file * definition (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with @@ -8,6 +8,9 @@ */ import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; + +export type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit'; @@ -30,11 +33,6 @@ export interface AgentFileDefinition { readonly source: AgentFileSource; } -export interface SkippedAgentFile { - readonly path: string; - readonly reason: string; -} - export interface AgentFileDiscoveryResult { readonly agents: readonly AgentFileDefinition[]; readonly skipped: readonly SkippedAgentFile[]; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts new file mode 100644 index 000000000..d0c66f18c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts @@ -0,0 +1,20 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IPluginAgentProfileLoader` contract. + * + * The plugin loader of the agent-profile extension point: owns the `plugin` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the enabled plugins' agent roots, tagged with this + * handler's `workspaceId`. `ready` tracks the most recent discovery pass; + * `reload()` re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IPluginAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IPluginAgentProfileLoader: ServiceIdentifier = + createDecorator('pluginAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts new file mode 100644 index 000000000..bbd32f77e --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts @@ -0,0 +1,79 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IPluginAgentProfileLoader` implementation. + * + * Discovers agent profiles contributed by enabled plugins (roots from the + * App-scope `plugins.pluginAgentRoots()`) and registers them via the shared + * loader skeleton. `${base_prompt}` is backed by the user loader's effective + * default profile. Reloads when plugins reload; install / enable / remove + * mutations deliberately do not re-register — those take effect on the next + * explicit reload. Bound at Workspace scope: agent-file discovery lives in + * the workspace layer alongside every other source. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { discoverAgentFiles } from './internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from './internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { IUserAgentProfileLoader } from './userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from './pluginAgentProfileLoader'; + +export class PluginAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IPluginAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'plugin'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin; + + constructor( + @IPluginService private readonly plugins: IPluginService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + ) { + super(registry, log); + this._register( + this.plugins.onDidReload(() => { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "plugin" reload failed: ${String(error)}`); + }); + }), + ); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + const roots = await this.plugins.pluginAgentRoots(); + return profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => { + this.log.warn(message); + }), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IPluginAgentProfileLoader, + PluginAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts new file mode 100644 index 000000000..adce0bf1a --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts @@ -0,0 +1,26 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IUserAgentProfileLoader` contract. + * + * The user loader of the agent-profile extension point: owns the `user` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the user agent roots under the os home, plus the + * `/SYSTEM.md` prompt-override profile appended after them — tagged + * with this handler's `workspaceId`. Also exposes the effective default + * profile (the `SYSTEM.md` override when present, else the builtin default, + * refreshed on each load pass) so every sibling loader can back + * `${base_prompt}` with it. `ready` tracks the most recent discovery pass; + * `reload()` re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +export interface IUserAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + getDefaultProfile(): AgentProfile; + reload(): Promise; +} + +export const IUserAgentProfileLoader: ServiceIdentifier = + createDecorator('userAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts new file mode 100644 index 000000000..c12b3a61c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts @@ -0,0 +1,97 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IUserAgentProfileLoader` implementation. + * + * Discovers user agent profiles through `bootstrap` home paths and `hostFs`, + * reports skipped files through `log`, and appends the `/SYSTEM.md` + * prompt-override profile (synthesized against the builtin default from the + * App builtin loader) after the scanned profiles so it wins same-name + * collisions within this contribution. The user roots are global os + * directories, but per-workspace registration keeps every contribution + * flowing through the same workspace-tagged lane, so the Session catalog + * projection needs no special case. Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { discoverAgentFiles } from './internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from './internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { userAgentRoots } from './internal/agentRoots'; +import { loadSystemMdProfile } from './internal/systemFile'; +import { IUserAgentProfileLoader } from './userAgentProfileLoader'; + +export class UserAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IUserAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'user'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user; + + private defaultProfile: AgentProfile; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IBuiltinAgentProfileLoader private readonly builtin: IBuiltinAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + ) { + super(registry, log); + this.defaultProfile = builtin.getDefault(); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + getDefaultProfile(): AgentProfile { + return this.defaultProfile; + } + + protected async load(): Promise { + const roots = await userAgentRoots( + this.fs, + this.bootstrap.homeDir, + this.bootstrap.osHomeDir, + (message, error) => { + this.log.warn(message, error); + }, + ); + const systemMd = await loadSystemMdProfile( + this.fs, + this.bootstrap.homeDir, + this.builtin.getDefault(), + (message) => this.log.warn(message), + ); + this.defaultProfile = systemMd ?? this.builtin.getDefault(); + const contribution = profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), + (context) => this.defaultProfile.systemPrompt(context), + ); + if (systemMd === undefined) return contribution; + return { ...contribution, profiles: [...contribution.profiles, systemMd] }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IUserAgentProfileLoader, + UserAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts new file mode 100644 index 000000000..077a080fb --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts @@ -0,0 +1,22 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IWorkspaceAgentProfileLoader` contract. + * + * The workspace loader of the agent-profile extension point: owns the + * `workspace` contribution in the App-scope `IAgentProfileRegistry` — the + * agent files discovered under this handler's project root, tagged with the + * handler's `workspaceId` so concurrent handlers never collide and the + * sessions of THIS workspace project exactly this entry. `ready` tracks the + * most recent discovery pass; `reload()` re-discovers and re-registers. + * Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IWorkspaceAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IWorkspaceAgentProfileLoader: ServiceIdentifier = + createDecorator('workspaceAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts new file mode 100644 index 000000000..d31299f09 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts @@ -0,0 +1,109 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IWorkspaceAgentProfileLoader` implementation. + * + * Discovers the workspace's agent files (`.kimi-code/agents`, `.agents/agents` + * under the project root, resolved through `workspaceContext` and `hostFs`) + * and registers them via the shared loader skeleton. `${base_prompt}` is + * backed by the user loader's effective default profile. Watches the project + * agent-root candidates through `hostFsWatch` (watched whether or not they + * exist yet) and reloads debounced, so a project agent-file change + * re-registers this contribution only. Bound at Workspace scope: the scan is + * per handler and the registration dies with it. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { projectAgentRootCandidates, projectAgentRoots } from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceAgentProfileLoader } from './workspaceAgentProfileLoader'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IWorkspaceAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'workspace'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.workspace; + + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly watchReady: Promise; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this.watchReady = this.watchProjectAgentRoots(); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + // The watch attaches before the first scan returns, so a change landing + // right after the scan cannot slip between the two. + await this.watchReady; + const roots = await projectAgentRoots(this.fs, this.workspace.cwd, (message, error) => { + this.log.warn(message, error); + }); + return profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } + + private async watchProjectAgentRoots(): Promise { + // Watch the project root recursively, pruned to the agent-root + // candidates: watching a candidate directory directly never fires when + // its parent (`.kimi-code` / `.agents`) does not exist yet either. + const { projectRoot, candidates } = await projectAgentRootCandidates( + this.fs, + this.workspace.cwd, + (message) => this.log.warn(message), + ); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "workspace" reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceAgentProfileLoader, + WorkspaceAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts new file mode 100644 index 000000000..7d6a99ca1 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts @@ -0,0 +1,56 @@ +/** + * `workspaceContext` domain (L1) — seeded per-handler workspace facts. + * + * Defines the `IWorkspaceContext` carrying the workspace handler's identity + * and storage addressing (`workspaceId`, `persistenceScope` — the handler's + * persistence scope string `sessions/{wd_id}` every session/agent scope + * string derives from), the workspace root (`cwd`) and catalog metadata + * (`meta`), plus the runtime keying pair (`osBackendId` × + * `persistenceBackendId`) that records which os/persistence backends the + * handler binds — both `'local'` until a remote runtime exists (`remoteCwd` + * reserves the remote root slot, never set by the local runtime). Seeded + * into the Workspace scope by `workspaceLifecycle` when the handler is + * materialized. Pure facts — no store, no IO. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +/** The only runtime source today; remote runtimes will add their own. */ +export type WorkspaceSource = 'local'; + +export const LOCAL_OS_BACKEND_ID = 'local'; +export const LOCAL_PERSISTENCE_BACKEND_ID = 'local'; + +/** + * Mirrors the `workspace` catalog's `Workspace` record field-for-field. + * Re-declared structurally so this L1 seed does not import the L2 catalog + * domain; a `Workspace` value is directly assignable here. + */ +export interface WorkspaceMeta { + readonly id: string; + readonly root: string; + readonly name: string; + readonly createdAt: number; + readonly lastOpenedAt: number; +} + +export interface IWorkspaceContext { + readonly _serviceBrand: undefined; + + readonly workspaceId: string; + readonly cwd: string; + readonly source: WorkspaceSource; + readonly remoteCwd?: string; + readonly meta: WorkspaceMeta; + readonly persistenceScope: string; + readonly osBackendId: string; + readonly persistenceBackendId: string; +} + +export const IWorkspaceContext: ServiceIdentifier = + createDecorator('workspaceContext'); + +export function workspaceContextSeed(ctx: IWorkspaceContext): ScopeSeed { + return [[IWorkspaceContext as ServiceIdentifier, ctx]]; +} diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts new file mode 100644 index 000000000..eabcf1ca2 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts @@ -0,0 +1,50 @@ +/** + * `workspaceDirs` domain (L3) — Workspace-scoped additional-directory set + * contract. + * + * Defines `IWorkspaceDirs`, the handler-level owner of the workspace's + * `{root, additionalDirs[]}` set: at handler materialization it loads the + * project-local `.kimi-code/local.toml` set; afterwards `addDir` mutations + * (persisted appends or session-caller in-memory unions) and fs watch on + * `local.toml` (cross-process edits) refresh the set, fanning the change + * out to every session of the handler through the `ISessionWorkspaceInfo` + * seed (`sessionInfo()`). The set is shared by all sessions of the + * workspace and persisted entries survive restarts; non-persisted entries + * live in handler memory only. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; + +export interface WorkspaceAddDirInput { + readonly path: string; + /** Persist to `.kimi-code/local.toml` (default true); false keeps the dir in handler memory only. */ + readonly persist?: boolean; +} + +export interface WorkspaceAdditionalDirsResult { + readonly projectRoot: string; + readonly configPath: string; + readonly additionalDirs: readonly string[]; + readonly persisted: boolean; +} + +export interface IWorkspaceDirs { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; + addDir(input: WorkspaceAddDirInput): Promise; + /** + * Union caller-provided dirs (session create/resume options, resolved + * against `baseDir`) into the shared in-memory set — never persisted. + */ + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise; + sessionInfo(): ISessionWorkspaceInfo; +} + +export const IWorkspaceDirs: ServiceIdentifier = + createDecorator('workspaceDirs'); diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts new file mode 100644 index 000000000..2207d31ab --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -0,0 +1,202 @@ +/** + * `workspaceDirs` domain (L3) — `IWorkspaceDirs` implementation. + * + * Holds the handler-shared additional-directory set as + * `fileDirs ∪ ephemeralDirs`: `fileDirs` is the project-local + * `.kimi-code/local.toml` set (loaded once per handler through + * `projectLocalConfig`, reloaded debounced when the fs watch sees the file + * change — including writes from OTHER processes), `ephemeralDirs` is the + * in-memory union of non-persisted `addDir` calls and caller-provided dirs + * from session create/resume options (it dies with the handler). Every + * mutation serializes on one tail queue; the change event fires only when + * the combined list actually changed. The set reaches every session of the + * handler through the `ISessionWorkspaceInfo` seed (`sessionInfo()`), a + * live read view over this service. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { + IWorkspaceDirs, + type WorkspaceAddDirInput, + type WorkspaceAdditionalDirsResult, +} from './workspaceDirs'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { + declare readonly _serviceBrand: undefined; + + private fileDirs: readonly string[] = []; + private ephemeralDirs: readonly string[] = []; + private projectRoot: string; + private configPath: string; + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private mutationTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.projectRoot = workspace.cwd; + this.configPath = ''; + this.ready = this.enqueue(() => this.reloadFromDisk()); + void this.ready.then(() => this.watchLocalToml()); + } + + get additionalDirs(): readonly string[] { + return [...new Set([...this.fileDirs, ...this.ephemeralDirs])]; + } + + addDir(input: WorkspaceAddDirInput): Promise { + return this.enqueue(() => this.applyAddDir(input)); + } + + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise { + if (dirs.length === 0) return Promise.resolve(); + return this.enqueue(async () => { + const resolved = await this.localConfig.resolveAdditionalDirs(baseDir, dirs); + if (this.unionEphemeral(resolved)) { + this.onDidChangeEmitter.fire(); + } + }); + } + + sessionInfo(): ISessionWorkspaceInfo { + const currentDirs = (): readonly string[] => this.additionalDirs; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get additionalDirs() { + return currentDirs(); + }, + }; + } + + private async applyAddDir(input: WorkspaceAddDirInput): Promise { + const persist = input.persist ?? true; + + if (persist) { + const persisted = await this.localConfig.appendAdditionalDir( + this.workspace.cwd, + input.path, + ); + this.projectRoot = persisted.projectRoot; + this.configPath = persisted.configPath; + const changed = this.setFileDirs(persisted.additionalDirs); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: persisted.projectRoot, + configPath: persisted.configPath, + additionalDirs: this.additionalDirs, + persisted: true, + }; + } + + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [ + input.path, + ]); + const changed = this.unionEphemeral(resolved); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: onDisk.projectRoot, + configPath: onDisk.configPath, + additionalDirs: this.additionalDirs, + persisted: false, + }; + } + + private async reloadFromDisk(): Promise { + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + if (this.setFileDirs(onDisk.additionalDirs)) { + this.onDidChangeEmitter.fire(); + } + } + + private setFileDirs(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.fileDirs = dirs; + return !sameStringList(before, this.additionalDirs); + } + + private unionEphemeral(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.ephemeralDirs = [...new Set([...this.ephemeralDirs, ...dirs])]; + return !sameStringList(before, this.additionalDirs); + } + + /** + * Watch the project root recursively, pruned to the `local.toml` + * candidate: watching the file directly never fires when its parent + * `.kimi-code` directory does not exist yet either. + * + * Runs only after `ready` resolves, so `reloadFromDisk` has already + * populated `projectRoot` / `configPath`. + */ + private watchLocalToml(): void { + try { + const handle = this.fsWatch.watch(this.projectRoot, { + recursive: true, + ignored: subtreeWatchFilter(this.projectRoot, [this.configPath]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.enqueue(() => this.reloadFromDisk()).catch((error) => { + this.log.warn(`local.toml reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch project-local config ${this.configPath}: ${String(error)}`); + } + } + + private enqueue(work: () => Promise): Promise { + const run = this.mutationTail.then(work, work); + this.mutationTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} + +function sameStringList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', +); diff --git a/packages/agent-core-v2/src/session/sessionFs/errors.ts b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts similarity index 94% rename from packages/agent-core-v2/src/session/sessionFs/errors.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/errors.ts index 058370a2f..dd5fe32d1 100644 --- a/packages/agent-core-v2/src/session/sessionFs/errors.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain error codes. + * `workspaceFs` domain error codes. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts similarity index 88% rename from packages/agent-core-v2/src/session/sessionFs/fs.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index f19320310..6443b326a 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -1,13 +1,14 @@ /** - * `sessionFs` domain (L2) — wire-shaped filesystem operations. + * `workspaceFs` domain (L3) — wire-shaped filesystem operations. * - * Defines the `ISessionFsService` that backs the fs REST surface: content search, - * content grep, and git status/diff, together with the zod DTO schemas the - * transports validate against. It orchestrates the os `IHostFileSystem` - * (file IO, resolved against the workspace root) plus `ISessionProcessRunner` - * (for `rg` / `git` / `gh`). Git status/diff DTOs live in the `git` domain. - * Session-scoped — the scope itself is the session, so no `sessionId` is - * threaded through. + * Defines the `IWorkspaceFsService` that backs the fs REST surface: content + * search, content grep, and git status/diff, together with the zod DTO + * schemas the transports validate against. It orchestrates the os + * `IHostFileSystem` (file IO, resolved against the workspace root) plus the + * handler-shared `ISessionProcessRunner` (for `rg`). Git status/diff DTOs + * live in the `git` domain. Workspace-scoped — one instance per handler, + * pinned to the handler root (chdir is gone, so the root never changes); the + * edge resolves it through any live session of the workspace. */ import { z } from 'zod'; @@ -180,7 +181,10 @@ export const fsStatManyResponseSchema = z.object({ export type FsStatManyResponse = z.infer; export const fsSearchRequestSchema = z.object({ - query: z.string().min(1), + // An empty query is allowed: the service then lists the workspace root's + // top-level entries (dirs first) instead of fuzzy-matching — the starting + // set for @-mention style file pickers. + query: z.string(), limit: z.number().int().min(1).max(200).default(50), include_globs: z.array(z.string()).optional(), exclude_globs: z.array(z.string()).optional(), @@ -231,7 +235,7 @@ export interface FsDownloadResolved { readonly modifiedAt: Date; } -export interface ISessionFsService { +export interface IWorkspaceFsService { readonly _serviceBrand: undefined; list(req: FsListRequest): Promise; @@ -248,5 +252,5 @@ export interface ISessionFsService { resolveDownload(relPath: string): Promise; } -export const ISessionFsService: ServiceIdentifier = - createDecorator('sessionFsService'); +export const IWorkspaceFsService: ServiceIdentifier = + createDecorator('workspaceFsService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts similarity index 95% rename from packages/agent-core-v2/src/session/sessionFs/fsProcess.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts index 2eabe9bb4..59c0a53b0 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain (L2) — `runCommand` helper over `ISessionProcessRunner`. + * `workspaceFs` domain (L3) — `runCommand` helper over `ISessionProcessRunner`. * * Collects a child process's full stdout/stderr and exit code through the * Agent's backend-pluggable `ISessionProcessRunner`, with optional `AbortSignal` diff --git a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts similarity index 98% rename from packages/agent-core-v2/src/session/sessionFs/fsSearch.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts index 0c4ed321f..cc4a20736 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain (L2) — pure search/grep helpers. + * `workspaceFs` domain (L3) — pure search/grep helpers. * * Fuzzy filename scoring, glob matching, grep-pattern compilation, and * ripgrep `--json` record parsing. No IO, no DI — plain functions so they can diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts similarity index 88% rename from packages/agent-core-v2/src/session/sessionFs/fsService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index 103924b82..d3922cd3b 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1,23 +1,25 @@ /** - * `sessionFs` domain (L2) — `ISessionFsService` implementation. + * `workspaceFs` domain (L3) — `IWorkspaceFsService` implementation. * * Backs the fs REST surface (search / grep / git status / git diff) by * orchestrating the os `IHostFileSystem` (file IO, resolved against the - * workspace root), `ISessionProcessRunner` (`rg`), and `IGitService` (git - * root and execution environment come from the scope, so no `sessionId` is - * threaded through. Git operations are delegated to the App-scoped - * `IGitService`; this service only confines paths and computes repo-relative - * paths before calling it. + * workspace root), the handler-shared `ISessionProcessRunner` (`rg`), and + * `IWorkspaceGitService` (git status/diff bound to the handler root; this + * service only confines paths and computes repo-relative paths before + * calling it). * - * Path confinement applies the lexical `ISessionWorkspaceContext.isWithin` - * check first, then re-verifies the candidate through `IHostFileSystem.realpath` - * (resolving the longest existing prefix, so not-yet-created paths still work): - * a symlink inside the workspace must not steer fs actions to files outside it. - * The plain-data state (`rgResolution`, `realRootsCache`) is registered into - * `sessionState` (`ISessionStateService`) and read/written through it. + * Path confinement applies a lexical within-workspace check first (the + * handler root plus the `workspaceDirs` additional-dir set, mirroring the + * Session-scope `workspaceContext` view semantics), then re-verifies the + * candidate through `IHostFileSystem.realpath` (resolving the longest + * existing prefix, so not-yet-created paths still work): a symlink inside + * the workspace must not steer fs actions to files outside it. The small + * caches (`rgResolution`, `realRootsCache`) are plain per-handler fields. + * Bound at Workspace scope — one instance per handler, shared by every + * session of the workspace. */ -import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { type FsDiffRequest, @@ -63,7 +65,6 @@ const FsWireErrorCode = { import ignore, { type Ignore } from 'ignore'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; import { buildEtag, countLines, @@ -73,14 +74,14 @@ import { guessMime, } from '#/_base/utils/fileMeta'; import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors'; -import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; import { ISessionProcessRunner } from '#/session/process/processRunner'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit'; -import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs'; +import { type FsDownloadResolved, type FsPathResolved, IWorkspaceFsService } from './fs'; import { readStream, runCommand } from './fsProcess'; import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator'; import { @@ -103,41 +104,43 @@ const FS_READ_MAX_BYTES = 10 * 1024 * 1024; const HIDDEN_NAME_RE = /^\./; const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); -export const sessionFsRgResolutionKey = defineState( - 'sessionFs.rgResolution', - () => undefined, -); -export const sessionFsRealRootsCacheKey = defineState< - { readonly key: string; readonly roots: readonly string[] } | undefined ->('sessionFs.realRootsCache', () => undefined); - -export class SessionFsService implements ISessionFsService { +export class WorkspaceFsService implements IWorkspaceFsService { declare readonly _serviceBrand: undefined; private readonly gitignoreCache = new Map(); + private rgResolution: RgResolution | null | undefined = undefined; + private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined = + undefined; + private readonly workDir: string; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ISessionProcessRunner private readonly runner: ISessionProcessRunner, @ITelemetryService private readonly telemetry: ITelemetryService, - @IGitService private readonly git: IGitService, + @IWorkspaceGitService private readonly git: IWorkspaceGitService, ) { - this.states.register(sessionFsRgResolutionKey); - this.states.register(sessionFsRealRootsCacheKey); + this.workDir = resolve(workspace.cwd); } - private get rgResolution(): RgResolution | null | undefined { - return this.states.get(sessionFsRgResolutionKey); + private resolvePathInput(rel: string): string { + return isAbsolute(rel) ? resolve(rel) : resolve(this.workDir, rel); } - private set rgResolution(value: RgResolution | null | undefined) { - this.states.set(sessionFsRgResolutionKey, value); + private isWithinWorkspace(absPath: string): boolean { + const target = resolve(absPath); + if (target === this.workDir) return true; + const rel = relative(this.workDir, target); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + return this.workspaceDirs.additionalDirs.some((dir) => { + const r = relative(resolve(dir), target); + return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + }); } private absOf(rel: string): string { - return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel); + return rel === '' || rel === '.' ? this.workDir : join(this.workDir, rel); } async list(req: FsListRequest): Promise { @@ -341,7 +344,7 @@ export class SessionFsService implements ISessionFsService { } catch (err) { throw mapFsError(err, req.path); } - const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + const name = rel === '.' ? basename(this.workDir) : basename(abs); return buildFsEntry(rel, name, st, true); } @@ -358,7 +361,7 @@ export class SessionFsService implements ISessionFsService { resolved.map(async ({ raw, rel, abs }) => { try { const st = await this.hostFs.lstat(abs); - const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + const name = rel === '.' ? basename(this.workDir) : basename(abs); entries[raw] = buildFsEntry(rel, name, st, false); } catch { entries[raw] = null; @@ -432,6 +435,35 @@ export class SessionFsService implements ISessionFsService { } async search(req: FsSearchRequest): Promise { + // Empty query: no fuzzy matching — list the workspace root's top-level + // entries (dirs first) so clients can show a starting set for @-mention + // style file pickers. + if (req.query === '') { + const listed = await this.list({ + path: '.', + depth: 1, + limit: req.limit, + show_hidden: false, + follow_gitignore: req.follow_gitignore, + exclude_globs: req.exclude_globs, + sort: 'type_first', + include_git_status: false, + }); + const items = listed.items + .filter( + (entry) => + req.include_globs === undefined || matchesAnyGlob(entry.path, req.include_globs), + ) + .map((entry) => ({ + path: entry.path, + name: entry.name, + kind: entry.kind, + score: 1, + match_positions: [], + })); + return { items, truncated: listed.truncated }; + } + const matcher = req.follow_gitignore ? await this.matcher() : undefined; const candidates: FsSearchHit[] = []; const queryLower = req.query.toLowerCase(); @@ -482,8 +514,6 @@ export class SessionFsService implements ISessionFsService { } async gitStatus(req: FsGitStatusRequest): Promise { - const cwd = this.workspace.workDir; - let filter: Set | undefined; if (req.paths !== undefined && req.paths.length > 0) { filter = new Set(); @@ -492,13 +522,12 @@ export class SessionFsService implements ISessionFsService { } } - return this.git.status(cwd, filter); + return this.git.status(filter); } async diff(req: FsDiffRequest): Promise { - const cwd = this.workspace.workDir; const abs = await this.resolveWithin(req.path); - return this.git.diff(cwd, this.toRel(abs), abs); + return this.git.diff(this.toRel(abs), abs); } private async grepWithRg( @@ -528,7 +557,7 @@ export class SessionFsService implements ISessionFsService { args.push(req.pattern); args.push('.'); - const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workspace.workDir }); + const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workDir }); const acc = new RgJsonAccumulator(req); let killed = false; @@ -688,13 +717,13 @@ export class SessionFsService implements ISessionFsService { } private async matcher(): Promise { - const cwd = this.workspace.workDir; + const cwd = this.workDir; const cached = this.gitignoreCache.get(cwd); if (cached !== undefined) return cached; const ig = ignore(); ig.add('.git/'); try { - const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore')); + const contents = await this.hostFs.readText(join(this.workDir, '.gitignore')); ig.add(contents); } catch { } @@ -705,7 +734,7 @@ export class SessionFsService implements ISessionFsService { private async resolveRg(): Promise { if (this.rgResolution !== undefined) return this.rgResolution; const probe: RgProbe = { - exec: (args) => runCommand(this.runner, args, { cwd: this.workspace.workDir }), + exec: (args) => runCommand(this.runner, args, { cwd: this.workDir }), }; try { this.rgResolution = await ensureRgPath(probe); @@ -715,20 +744,8 @@ export class SessionFsService implements ISessionFsService { return this.rgResolution; } - private get realRootsCache(): - | { readonly key: string; readonly roots: readonly string[] } - | undefined { - return this.states.get(sessionFsRealRootsCacheKey); - } - - private set realRootsCache( - value: { readonly key: string; readonly roots: readonly string[] } | undefined, - ) { - this.states.set(sessionFsRealRootsCacheKey, value); - } - private async realRoots(): Promise { - const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs]; + const dirs = [this.workDir, ...this.workspaceDirs.additionalDirs.map((d) => resolve(d))]; const key = dirs.join('\n'); if (this.realRootsCache?.key === key) return this.realRootsCache.roots; const roots: string[] = []; @@ -778,8 +795,8 @@ export class SessionFsService implements ISessionFsService { details: { path: inputPath, reason: 'dotdot_segment' }, }); } - const abs = this.workspace.resolve(inputPath); - if (!this.workspace.isWithin(abs)) { + const abs = this.resolvePathInput(inputPath); + if (!this.isWithinWorkspace(abs)) { throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { details: { path: inputPath, reason: 'resolved_outside' }, }); @@ -797,7 +814,7 @@ export class SessionFsService implements ISessionFsService { } private toRel(abs: string): string { - const cwd = this.workspace.workDir; + const cwd = this.workDir; if (abs === cwd) return '.'; const rel = relative(cwd, abs); if (rel === '') return '.'; @@ -1027,9 +1044,9 @@ function toWireError(err: unknown): { code: number; msg: string } { } registerScopedService( - LifecycleScope.Session, - ISessionFsService, - SessionFsService, + LifecycleScope.Workspace, + IWorkspaceFsService, + WorkspaceFsService, ScopeActivation.OnScopeCreated, - 'sessionFs', + 'workspaceFs', ); diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts new file mode 100644 index 000000000..2ec869bd3 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts @@ -0,0 +1,55 @@ +/** + * `workspaceFs` domain (L3) — workspace-confined filesystem change feed. + * + * Defines the `IWorkspaceFsWatchService` that turns the os + * `IHostFsWatchService` raw events into a workspace-relative, debounced, + * `.gitignore`-aware change feed (`FsChangeEvent`) for the whole handler. + * One os watcher on the workspace root is shared by every subscriber — + * subscribers are the sessions of this workspace (via the transport's + * fs-watch bridge) and any Workspace-scope service that wants change + * notifications. Each subscription declares the set of workspace-relative + * paths it cares about; events outside that subtree are dropped, and every + * subscription gets its own debounce window and truncation counters, so a + * per-session feed through a subscription is indistinguishable from the old + * per-session watch service. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; + +export type FsChangeKind = 'file' | 'directory' | 'symlink'; + +export type FsChangeAction = 'created' | 'modified' | 'deleted'; + +export interface FsChangeEntry { + path: string; + change: FsChangeAction; + kind: FsChangeKind; + size_delta?: number | undefined; + etag?: string | undefined; +} + +export interface FsChangeEvent { + changes: FsChangeEntry[]; + coalesced_window_ms: number; + truncated?: boolean | undefined; + count?: number | undefined; +} + +export interface IWorkspaceFsWatchSubscription extends IDisposable { + setWatchedPaths(paths: readonly string[]): void; + + readonly watchedPaths: readonly string[]; + + readonly onDidChangeFiles: Event; +} + +export interface IWorkspaceFsWatchService { + readonly _serviceBrand: undefined; + + subscribe(): IWorkspaceFsWatchSubscription; +} + +export const IWorkspaceFsWatchService: ServiceIdentifier = + createDecorator('workspaceFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts similarity index 54% rename from packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts index e335f708b..33b4e709f 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts @@ -1,25 +1,25 @@ /** - * `sessionFsWatch` domain (L2) — `ISessionFsWatchService` implementation. + * `workspaceFs` domain (L3) — `IWorkspaceFsWatchService` implementation. * - * Subscribes to the os `IHostFsWatchService` on the workspace root, confines - * events to the caller-declared subtree and to non-`.gitignore`d paths, - * debounces them into fixed windows and re-exposes them as workspace-relative - * `FsChangeEvent`s. The os watcher is started lazily on the first non-empty - * subscription and stopped when the subscription set becomes empty. The - * plain-data state (`watched`, `pending`, `rawCount`, `truncated`, - * `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`) - * and read/written through it. Path confinement is lexical - * (`ISessionWorkspaceContext.isWithin`), matching `sessionFs`. + * Keeps ONE os `IHostFsWatchService` subscription on the handler root and + * fans its raw events out to every `IWorkspaceFsWatchSubscription`: the + * shared leg (the os handle plus the `.gitignore` matcher) runs once per + * handler, the per-subscriber leg (subtree confinement, debounce window, + * overflow truncation) runs once per subscription, so two sessions of the + * same workspace never hang a second os watcher. The os handle starts + * lazily when the first subscription declares a non-empty path set and + * stops when no subscription watches anything. Path confinement is lexical + * (the handler root plus the `workspaceDirs` additional-dir set), matching + * the rest of `workspaceFs`. Bound at Workspace scope. */ -import { isAbsolute, join, relative, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -27,11 +27,15 @@ import { type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { FsChangeEntry, FsChangeEvent } from './fsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { ISessionFsWatchService } from './fsWatch'; +import { + type FsChangeEntry, + type FsChangeEvent, + IWorkspaceFsWatchService, + type IWorkspaceFsWatchSubscription, +} from './fsWatch'; const DEFAULT_DEBOUNCE_MS = 200; const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; @@ -43,117 +47,62 @@ function readPositiveIntEnv(name: string, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } -export const sessionFsWatchWatchedKey = defineState>( - 'sessionFsWatch.watched', - () => new Set(), -); -export const sessionFsWatchPendingKey = defineState('sessionFsWatch.pending', () => []); -export const sessionFsWatchRawCountKey = defineState('sessionFsWatch.rawCount', () => 0); -export const sessionFsWatchTruncatedKey = defineState('sessionFsWatch.truncated', () => false); -export const sessionFsWatchGitignoreLoadedKey = defineState( - 'sessionFsWatch.gitignoreLoaded', - () => false, -); - -export class SessionFsWatchService extends Disposable implements ISessionFsWatchService { +export class WorkspaceFsWatchService extends Disposable implements IWorkspaceFsWatchService { declare readonly _serviceBrand: undefined; - private readonly emitter = this._register(new Emitter()); - readonly onDidChangeFiles: Event = this.emitter.event; - + private readonly subscriptions = new Set(); private handle: IHostFsWatchHandle | undefined; private handleSub: IDisposable | undefined; - - private debounceTimer: NodeJS.Timeout | undefined; - - private readonly debounceMs = readPositiveIntEnv( - 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', - DEFAULT_DEBOUNCE_MS, - ); - private readonly maxChangesPerWindow = readPositiveIntEnv( - 'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', - DEFAULT_MAX_CHANGES_PER_WINDOW, - ); - + private gitignoreLoaded = false; private readonly matcher: Ignore = ignore().add('.git/'); + private readonly workDir: string; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService, @IHostFileSystem private readonly hostFs: IHostFileSystem, ) { super(); - this.states.register(sessionFsWatchWatchedKey); - this.states.register(sessionFsWatchPendingKey); - this.states.register(sessionFsWatchRawCountKey); - this.states.register(sessionFsWatchTruncatedKey); - this.states.register(sessionFsWatchGitignoreLoadedKey); + this.workDir = resolve(workspace.cwd); } - private get watched(): Set { - return this.states.get(sessionFsWatchWatchedKey); + subscribe(): IWorkspaceFsWatchSubscription { + const subscription = new WorkspaceFsWatchSubscription(this); + this.subscriptions.add(subscription); + return subscription; } - private set watched(value: Set) { - this.states.set(sessionFsWatchWatchedKey, value); - } - - private get pending(): FsChangeEntry[] { - return this.states.get(sessionFsWatchPendingKey); - } - - private set pending(value: FsChangeEntry[]) { - this.states.set(sessionFsWatchPendingKey, value); - } - - private get rawCount(): number { - return this.states.get(sessionFsWatchRawCountKey); - } - - private set rawCount(value: number) { - this.states.set(sessionFsWatchRawCountKey, value); - } - - private get truncated(): boolean { - return this.states.get(sessionFsWatchTruncatedKey); - } - - private set truncated(value: boolean) { - this.states.set(sessionFsWatchTruncatedKey, value); - } - - private get gitignoreLoaded(): boolean { - return this.states.get(sessionFsWatchGitignoreLoadedKey); - } - - private set gitignoreLoaded(value: boolean) { - this.states.set(sessionFsWatchGitignoreLoadedKey, value); - } - - get watchedPaths(): readonly string[] { - return Array.from(this.watched); - } - - setWatchedPaths(paths: readonly string[]): void { + /** Subscription → service: confinement validation + rel normalization. */ + normalizeWatchedPaths(paths: readonly string[]): Set { const next = new Set(); for (const p of paths) { const abs = this.resolveWithin(p); next.add(this.toRel(abs)); } - this.watched = next; - if (next.size === 0) { - this.teardownHandle(); - this.clearWindow(); - return; + return next; + } + + /** Subscription → service: a subscription's path set changed (or it disposed). */ + syncHandle(): void { + for (const sub of this.subscriptions) { + if (sub.hasPaths()) { + this.ensureHandle(); + return; + } } - this.ensureHandle(); + this.teardownHandle(); + } + + dropSubscription(subscription: WorkspaceFsWatchSubscription): void { + this.subscriptions.delete(subscription); + this.syncHandle(); } private ensureHandle(): void { if (this.handle !== undefined) return; this.loadGitignore(); - const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); + const handle = this.hostFsWatch.watch(this.workDir, { recursive: true }); this.handle = handle; this.handleSub = handle.onDidChange((e) => this.onRaw(e)); } @@ -168,14 +117,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private loadGitignore(): void { if (this.gitignoreLoaded) return; this.gitignoreLoaded = true; - void this.hostFs - .readText(join(this.workspace.workDir, '.gitignore')) - .then( - (content) => { - this.matcher.add(content); - }, - () => undefined, - ); + void this.hostFs.readText(join(this.workDir, '.gitignore')).then( + (content) => { + this.matcher.add(content); + }, + () => undefined, + ); } private onRaw(e: HostFsChange): void { @@ -183,8 +130,111 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch if (rel === '.') return; const probe = e.kind === 'directory' ? `${rel}/` : rel; if (this.matcher.ignores(probe)) return; - if (!isUnderAny(rel, this.watched)) return; + for (const sub of this.subscriptions) { + sub.onRawChange(rel, e); + } + } + override dispose(): void { + // `sub.dispose()` removes the subscription from the set; deleting the + // current element mid-iteration is safe for JS Sets. + for (const sub of this.subscriptions) { + sub.dispose(); + } + this.subscriptions.clear(); + this.teardownHandle(); + super.dispose(); + } + + private resolveWithin(inputPath: string): string { + if (inputPath === '' || inputPath === '/') { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { + details: { path: inputPath, reason: 'empty' }, + }); + } + if (isAbsolute(inputPath)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, { + details: { path: inputPath, reason: 'absolute' }, + }); + } + const segments = inputPath.split(/[/\\]+/); + if (segments.some((s) => s === '..')) { + throw new Error2( + ErrorCodes.FS_PATH_ESCAPES, + `path "${inputPath}" rejected (dotdot segment)`, + { details: { path: inputPath, reason: 'dotdot_segment' } }, + ); + } + const abs = isAbsolute(inputPath) ? resolve(inputPath) : resolve(this.workDir, inputPath); + if (!this.isWithinWorkspace(abs)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { + details: { path: inputPath, reason: 'resolved_outside' }, + }); + } + return abs; + } + + private isWithinWorkspace(absPath: string): boolean { + const target = resolve(absPath); + if (target === this.workDir) return true; + const rel = relative(this.workDir, target); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + return this.workspaceDirs.additionalDirs.some((dir) => { + const r = relative(resolve(dir), target); + return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + }); + } + + private toRel(abs: string): string { + const cwd = this.workDir; + if (abs === cwd) return '.'; + const rel = relative(cwd, abs); + if (rel === '') return '.'; + return rel.split(sep).join('/'); + } +} + +class WorkspaceFsWatchSubscription implements IWorkspaceFsWatchSubscription { + private readonly emitter = new Emitter(); + readonly onDidChangeFiles: Event = this.emitter.event; + + private watched = new Set(); + private pending: FsChangeEntry[] = []; + private rawCount = 0; + private truncated = false; + private debounceTimer: NodeJS.Timeout | undefined; + private disposed = false; + + private readonly debounceMs = readPositiveIntEnv( + 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', + DEFAULT_DEBOUNCE_MS, + ); + private readonly maxChangesPerWindow = readPositiveIntEnv( + 'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', + DEFAULT_MAX_CHANGES_PER_WINDOW, + ); + + constructor(private readonly owner: WorkspaceFsWatchService) {} + + get watchedPaths(): readonly string[] { + return Array.from(this.watched); + } + + hasPaths(): boolean { + return !this.disposed && this.watched.size > 0; + } + + setWatchedPaths(paths: readonly string[]): void { + if (this.disposed) return; + this.watched = this.owner.normalizeWatchedPaths(paths); + if (this.watched.size === 0) { + this.clearWindow(); + } + this.owner.syncHandle(); + } + + onRawChange(rel: string, e: HostFsChange): void { + if (this.disposed || !isUnderAny(rel, this.watched)) return; this.pending.push({ path: rel, change: e.action, kind: e.kind }); this.rawCount += 1; if (this.pending.length > this.maxChangesPerWindow) { @@ -200,7 +250,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private flush(): void { this.debounceTimer = undefined; - if (this.rawCount === 0) return; + if (this.disposed || this.rawCount === 0) return; const truncated = this.truncated; const count = this.rawCount; const changes = truncated ? [] : this.pending; @@ -226,46 +276,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.truncated = false; } - override dispose(): void { + dispose(): void { + if (this.disposed) return; + this.disposed = true; this.clearWindow(); - this.teardownHandle(); - super.dispose(); - } - - private resolveWithin(inputPath: string): string { - if (inputPath === '' || inputPath === '/') { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { - details: { path: inputPath, reason: 'empty' }, - }); - } - if (isAbsolute(inputPath)) { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, { - details: { path: inputPath, reason: 'absolute' }, - }); - } - const segments = inputPath.split(/[/\\]+/); - if (segments.some((s) => s === '..')) { - throw new Error2( - ErrorCodes.FS_PATH_ESCAPES, - `path "${inputPath}" rejected (dotdot segment)`, - { details: { path: inputPath, reason: 'dotdot_segment' } }, - ); - } - const abs = this.workspace.resolve(inputPath); - if (!this.workspace.isWithin(abs)) { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { - details: { path: inputPath, reason: 'resolved_outside' }, - }); - } - return abs; - } - - private toRel(abs: string): string { - const cwd = this.workspace.workDir; - if (abs === cwd) return '.'; - const rel = relative(cwd, abs); - if (rel === '') return '.'; - return rel.split(sep).join('/'); + this.emitter.dispose(); + this.owner.dropSubscription(this); } } @@ -279,9 +295,9 @@ function isUnderAny(rel: string, parents: ReadonlySet): boolean { } registerScopedService( - LifecycleScope.Session, - ISessionFsWatchService, - SessionFsWatchService, + LifecycleScope.Workspace, + IWorkspaceFsWatchService, + WorkspaceFsWatchService, ScopeActivation.OnScopeCreated, - 'sessionFsWatch', + 'workspaceFs', ); diff --git a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts similarity index 84% rename from packages/agent-core-v2/src/session/sessionFs/rgLocator.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts index 178514afc..a5d763076 100644 --- a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts @@ -1,12 +1,12 @@ /** - * `sessionFs` domain — shared ripgrep (`rg`) binary locator. + * `workspaceFs` domain — shared ripgrep (`rg`) binary locator. * - * Single place that decides which `rg` the Glob and Grep paths run. The lookup - * mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation) - * but is driven through a caller-supplied {@link RgProbe} so it works against - * whatever execution environment the caller has — Glob probes through the - * session `ISessionProcessRunner`, Grep through the shared runner as well. - * Both run `rg --version` and treat exit code 0 as "available". + * Single place that decides which `rg` the fs search/grep paths run. The + * lookup mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful + * degradation) but is driven through a caller-supplied {@link RgProbe} so it + * works against whatever execution environment the caller has — the fs + * surface probes through the handler-shared `ISessionProcessRunner`, running + * `rg --version` and treating exit code 0 as "available". * * Lookup order (first hit wins): * 1. System `rg` on the execution-environment PATH (`rg --version`). diff --git a/packages/agent-core-v2/src/session/sessionFs/runRg.ts b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts similarity index 85% rename from packages/agent-core-v2/src/session/sessionFs/runRg.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts index 46ab22342..ccb605119 100644 --- a/packages/agent-core-v2/src/session/sessionFs/runRg.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts @@ -1,17 +1,13 @@ /** - * `sessionFs` domain — shared ripgrep subprocess plumbing. + * `workspaceFs` domain — shared ripgrep subprocess plumbing. * - * Single place that knows how Glob spawns `rg` through the session - * `ISessionProcessRunner`: timeout / abort handling, capped stdout / stderr - * draining, two-phase kill with process disposal, and the EAGAIN retry - * predicate. Mode-specific argument building and output parsing stay in the - * tools themselves. - * - * Ported from v1 (`packages/agent-core/src/tools/support/run-rg.ts`) onto the - * v2 `ISessionProcessRunner`. Grep keeps its own `runCommand` path in - * `fsService` (it streams JSON and has a pure-node fallback); this helper is - * shared in the sense that the previously inline Glob plumbing now lives in one - * reusable module under the same `sessionFs` domain as Grep's search code. + * Timeout / abort handling, capped stdout / stderr draining, two-phase kill + * with process disposal, and the EAGAIN retry predicate for spawning `rg` + * through the handler-shared `ISessionProcessRunner`. Ported from v1 + * (`packages/agent-core/src/tools/support/run-rg.ts`). The fs surface's Grep + * keeps its own `runCommand` path in `fsService` (it streams JSON and has a + * pure-node fallback); this helper is the reusable module for callers that + * want the simpler buffered shape. */ import type { Readable } from 'node:stream'; diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts new file mode 100644 index 000000000..615838088 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -0,0 +1,22 @@ +/** + * `workspaceGit` domain (L2) — handler-root-bound git facade contract. + * + * Defines the `IWorkspaceGitService`, a thin facade over the App-scope + * `IGitService` pinned to this handler's workspace root: callers pass + * repo-relative paths only, never a `cwd`. The PR-status cache stays in the + * App-scope `IGitService`, keyed by `cwd` with a 60 s TTL — one entry per + * workspace root, which is exactly per-handler ownership. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { FsDiffResponse, FsGitStatusResponse } from '#/app/git/git'; + +export interface IWorkspaceGitService { + readonly _serviceBrand: undefined; + + status(pathFilter?: ReadonlySet): Promise; + diff(relPath: string, absPath: string): Promise; +} + +export const IWorkspaceGitService: ServiceIdentifier = + createDecorator('workspaceGitService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts new file mode 100644 index 000000000..45695c827 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -0,0 +1,39 @@ +/** + * `workspaceGit` domain (L2) — `IWorkspaceGitService` implementation. + * + * Delegates every call to the App-scope `IGitService` with `cwd` pinned to + * the handler's workspace root (`IWorkspaceContext.cwd`). Owns no state: the + * PR-status cache (keyed by `cwd`, 60 s TTL) already lives in the App-scope + * service, which makes it per-handler in effect. Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type FsDiffResponse, type FsGitStatusResponse, IGitService } from '#/app/git/git'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceGitService } from './workspaceGit'; + +export class WorkspaceGitService implements IWorkspaceGitService { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IGitService private readonly git: IGitService, + ) {} + + status(pathFilter?: ReadonlySet): Promise { + return this.git.status(this.workspace.cwd, pathFilter); + } + + diff(relPath: string, absPath: string): Promise { + return this.git.diff(this.workspace.cwd, relPath, absPath); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceGitService, + WorkspaceGitService, + ScopeActivation.OnScopeCreated, + 'workspaceGit', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts new file mode 100644 index 000000000..83cc605a9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts @@ -0,0 +1,30 @@ +/** + * `workspaceHandler` domain (L6) — persistence addressing along the handler chain. + * + * Pure functions deriving the persistence scope strings and on-disk + * directories from the handler's `persistenceScope` (`sessions/{wd_id}`): + * session = `{handlerScope}/{session_id}`, agent = + * `{sessionScope}/agents/{agent_id}`. Under the local/local runtime these + * are byte-identical to the layout the pre-Workspace engine wrote, so v1 + * readers (`session_index.jsonl`, snapshot readers) keep working unchanged. + * Own no scoped state. + */ + +import { join } from 'pathe'; + +/** The handler's persistence scope: `sessions/{wd_id}`. */ +export function workspacePersistenceScope(sessionsScope: string, workspaceId: string): string { + return join(sessionsScope, workspaceId); +} + +export function sessionScopeOf(handlerScope: string, sessionId: string): string { + return `${handlerScope}/${sessionId}`; +} + +export function sessionDirOf(homeDir: string, handlerScope: string, sessionId: string): string { + return join(homeDir, sessionScopeOf(handlerScope, sessionId)); +} + +export function agentScopeOf(sessionScope: string, agentId: string): string { + return `${sessionScope}/agents/${agentId}`; +} diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts similarity index 51% rename from packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts rename to packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts index c57bb8d21..a97aa1c9a 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts @@ -1,31 +1,42 @@ /** - * `sessionLifecycle` domain (L6) — creates and tracks sessions at the process root. + * `workspaceHandler` domain (L6) — per-handler session lifecycle contract. * - * Defines the public contract of session lifecycle: the `CreateSessionOptions`, - * `ForkSessionOptions`, `CreateChildSessionOptions`, and the - * `ISessionLifecycleService` used to create sessions (`create`), look up the - * live ones (`get` / `list`), close them (`close`), archive/restore them, - * fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces - * lifecycle transitions through ordered hook slots plus - * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / - * `onDidForkSession`. App-scoped — a single - * process-wide instance owns the live session scope tree. Persisted - * sessions (open or closed) are the `sessionIndex` read model; per-session - * behaviour lives in the Session-scoped domains. + * Defines the public contract of one workspace handler: the + * `CreateSessionOptions`, `ForkSessionOptions`, `CreateChildSessionOptions`, + * `ResumeSessionOptions`, and the `IWorkspaceHandlerService` used to create + * sessions (`create`), look up the live ones (`get` / `list`), close them + * (`close`), archive/restore them, fork them (`fork`), and fork-then-tag + * them as direct children (`createChild`) — always as child scopes of THIS + * handler's Workspace scope, so a handler owns exactly the sessions of one + * workspace and fork never crosses handlers. Announces lifecycle transitions + * through `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` + * / `onDidForkSession`; the ordered hook slots are per-session seeds + * (`sessionLifecycleHooks`). Workspace-scoped — one instance per + * materialized handler. Persisted sessions (open or closed) are the + * `sessionIndex` read model; per-session behaviour lives in the + * Session-scoped domains. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { BindAgentInput } from '#/agent/profile/profile'; -import type { Hooks } from '#/hooks'; +import type { + SessionCloseReason, + SessionCreateSource, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; + +export type { SessionCloseReason, SessionCreateSource }; export interface CreateSessionOptions { readonly sessionId?: string; readonly workDir: string; + /** + * Caller-provided additional workspace directories, resolved against + * `workDir` and unioned into the handler's SHARED in-memory set (every + * session of this workspace sees them; never persisted). + */ readonly additionalDirs?: readonly string[]; - readonly mcpServers?: Readonly>; readonly mainAgentBinding?: BindAgentInput; } @@ -36,6 +47,15 @@ export interface ForkSessionOptions { readonly metadata?: Record; } +export interface ResumeSessionOptions { + /** + * Caller-provided additional workspace directories, re-resolved against the + * session workDir and unioned into the handler's shared in-memory set (same + * semantics as `CreateSessionOptions.additionalDirs`). + */ + readonly additionalDirs?: readonly string[]; +} + export interface CreateChildSessionOptions { readonly sourceSessionId: string; readonly newSessionId?: string; @@ -53,21 +73,12 @@ export interface SessionClosedEvent { readonly sessionId: string; } -export type SessionCreateSource = 'startup' | 'resume' | 'fork'; - -export type SessionCloseReason = 'exit'; - export interface SessionWillCloseEvent { readonly sessionId: string; readonly handle: ISessionScopeHandle; readonly reason: SessionCloseReason; } -export type SessionLifecycleHooks = { - readonly onDidCreateSession: SessionCreatedEvent; - readonly onWillCloseSession: SessionWillCloseEvent; -}; - export interface SessionArchivedEvent { readonly sessionId: string; } @@ -78,18 +89,17 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } -export interface ISessionLifecycleService { +export interface IWorkspaceHandlerService { readonly _serviceBrand: undefined; readonly onDidCreateSession: Event; readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; - readonly hooks: Hooks; create(opts: CreateSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; list(): readonly ISessionScopeHandle[]; - resume(sessionId: string): Promise; + resume(sessionId: string, opts?: ResumeSessionOptions): Promise; close(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; @@ -97,5 +107,5 @@ export interface ISessionLifecycleService { createChild(opts: CreateChildSessionOptions): Promise; } -export const ISessionLifecycleService: ServiceIdentifier = - createDecorator('sessionLifecycleService'); +export const IWorkspaceHandlerService: ServiceIdentifier = + createDecorator('workspaceHandlerService'); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts similarity index 61% rename from packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts rename to packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index accd49cfd..09d18a235 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -1,34 +1,58 @@ /** - * `sessionLifecycle` domain (L6) — `ISessionLifecycleService` implementation. + * `workspaceHandler` domain (L6) — `IWorkspaceHandlerService` implementation. * - * Owns the process-wide registry of open Session child scopes, creating them - * through the DI scope tree and seeding each with its identity and storage - * addressing, running lifecycle hook slots, and tearing them down on - * close/archive — archiving flags the session's `sessionMetadata`, removes - * its `agentLifecycle` agents, restoring clears the archived flag, and - * broadcasts through `event`; session start and resume failures are reported - * through `telemetry`. Each Session scope receives a telemetry view bound to - * its session id, while failures before a scope is available use an ephemeral - * context view. + * Owns the registry of THIS handler's open Session child scopes, creating + * them through the DI scope tree (children of the handler's Workspace + * scope) and seeding each with its identity, storage addressing derived + * from the handler's `workspaceContext.persistenceScope`, and a per-session + * `sessionLifecycleHooks` slots instance it runs around create/close, + * tearing sessions down on close/archive — archiving flags the session's + * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears + * the archived flag, and broadcasts through `event`; session start and + * resume failures are reported through `telemetry`. Each Session scope + * receives a telemetry view bound to its session id, while failures before + * a scope is available use an ephemeral context view. Closing a session + * never touches the handler itself. + * Every Session scope is also seeded with the handler's shared workspace + * resources as pure-data read views (the injection contracts): + * `sessionSkillCatalogData` (the merged skill catalog), + * `sessionAgentProfileCatalogSeed` (ONLY the handler's workspace id — the + * Session-scope catalog reads the App-scope agent-profile registry directly + * and picks out the contributions tagged with this key), + * `sessionInstructionsProvider` (the AGENTS.md snapshot), + * `sessionMcpHandle` (the one shared MCP connection manager), + * `sessionWorkspaceInfo` (the shared additional-directory set — caller + * `additionalDirs` options union into it at materialization; the + * `workspaceDirs` service owns persistence and the `local.toml` watch), and + * `sessionToolPolicyGate` (the workspace's os-level tool veto) — discovery, + * watching and connecting all live on the Workspace-scope services; session + * consumers read the seeds and refresh off their change events. * Materializes the session's initial metadata on - * creation by resolving `sessionMetadata`. Bound at App scope. Persisted - * sessions are discovered through the `sessionIndex` read model, and workspace - * roots are remembered through `workspace`. On create / fork the + * creation by resolving `sessionMetadata`. Bound at Workspace scope. + * Persisted sessions are discovered through the `sessionIndex` read model. + * On create / fork the * session is also appended to the shared `session_index.jsonl` so v1 clients * (TUI, export) can discover sessions created by the v2 engine; the entry is - * indexed under the registry-resolved workspace id — the same id seeding the + * indexed under the handler's workspace id — the same id seeding the * session's storage scope — so an alias spelling of the workDir cannot split * the session into a bucket v1 readers never look in. Fork flushes * live Agent wire journals, normalizes a missing protocol envelope, and - * appends the fork boundary before restoring the target Agent. On - * materialize, the session's metadata, tool policy, and agent-profile catalog - * are awaited before the handle is published — agent-file discovery is local- + * appends the fork boundary before restoring the target Agent; fork is + * confined to this handler (source and target share the workspace bucket). + * On + * materialize, the agent-profile loaders' `ready` is awaited + * before the handle is published — agent-file discovery is local- * fs and cheap, and a resumed session's first turn must see file-defined - * agent types in the `Agent` tool description; the catalog's `ready` only - * rejects for a fatal explicit-source error, exactly the case that should + * agent types in the `Agent` tool description; only the `fatal` explicit + * loader rejects, exactly the case that should * fail fast, and on that failure the half-materialized handle is disposed - * instead of poisoning the session cache (the skill catalog, by contrast, is - * kicked fire-and-forget). The session-level services whose subscriptions + * instead of poisoning the session cache, and the explicit loader is re-armed + * with a fire-and-forget `reload()` so a fixed agent file unblocks later + * creates + * (the workspace skill catalog, by contrast, is kicked fire-and-forget). + * The handler's shared MCP manager (file + plugin servers only — sessions + * cannot contribute servers) is awaited before create/resume returns. The + * session-level services whose subscriptions * must exist before the first agent / turn (external hooks, cron, the * secondary-model startup warning) opt into `OnScopeCreated` activation. */ @@ -62,8 +86,6 @@ import { ISessionIndex, PARENT_SESSION_ID_KEY, } from '#/app/sessionIndex/sessionIndex'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { IWorkspaceService } from '#/app/workspace/workspace'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { createHooks } from '#/hooks'; @@ -73,40 +95,65 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { sessionMcpHandleSeed } from '#/session/mcp/sessionMcpHandle'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; +import { sessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { sessionInstructionsProviderSeed } from '#/session/sessionInstructions/instructionsProvider'; +import { sessionWorkspaceInfoSeed } from '#/session/workspaceInfo/workspaceInfo'; +import { + ISessionLifecycleHooks, + sessionLifecycleHooksSeed, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { sessionToolPolicyGateSeed } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, createWireMetadataRecord, type WireRecord, } from '#/wire/record'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { + IExplicitAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { + IExtraAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { + IWorkspaceAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { agentScopeOf, sessionDirOf, sessionScopeOf } from './addressing'; import { type CreateChildSessionOptions, type CreateSessionOptions, type ForkSessionOptions, + type ResumeSessionOptions, type SessionArchivedEvent, type SessionClosedEvent, type SessionCreatedEvent, type SessionForkedEvent, - type SessionLifecycleHooks, type SessionWillCloseEvent, - ISessionLifecycleService, -} from './sessionLifecycle'; + IWorkspaceHandlerService, +} from './workspaceHandler'; type MaterializeSessionOptions = Omit & { readonly sessionId: string; - readonly workspaceId?: string; }; -export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { +export class WorkspaceHandlerService extends Disposable implements IWorkspaceHandlerService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); @@ -117,14 +164,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly onDidArchiveSession: Event = this._onDidArchiveSession.event; private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; - readonly hooks = createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]); private readonly resuming = new Map>(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, + @IWorkspaceContext private readonly workspaceContext: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @IHostEnvironment private readonly hostEnv: IHostEnvironment, @@ -133,15 +177,36 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ICronTaskPersistence private readonly cronStore: ICronTaskPersistence, - @IWorkspaceService private readonly workspaces: IWorkspaceService, - @IProjectLocalConfigService - private readonly projectLocalConfig: IProjectLocalConfigService, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, + @IWorkspaceSkillCatalog private readonly skillCatalog: IWorkspaceSkillCatalog, + @IWorkspaceAgentProfileLoader + private readonly workspaceAgentProfileLoader: IWorkspaceAgentProfileLoader, + @IExtraAgentProfileLoader + private readonly extraAgentProfileLoader: IExtraAgentProfileLoader, + @IExplicitAgentProfileLoader + private readonly explicitAgentProfileLoader: IExplicitAgentProfileLoader, + @IUserAgentProfileLoader + private readonly userAgentProfileLoader: IUserAgentProfileLoader, + @IPluginAgentProfileLoader + private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, + @IWorkspaceInstructionsService private readonly instructions: IWorkspaceInstructionsService, + @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, + @IWorkspaceToolPolicy private readonly toolPolicy: IWorkspaceToolPolicy, + @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, ) { super(); } + private get workspaceId(): string { + return this.workspaceContext.workspaceId; + } + + private get handlerScope(): string { + return this.workspaceContext.persistenceScope; + } + async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); const handle = await this.materializeSession({ ...opts, sessionId }); @@ -157,15 +222,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const planAgent = main ?? (await ensureMainAgent(handle)); await planAgent.accessor.get(IAgentPlanService).enter(); } - // Index the session under the workspace id the registry actually resolved - // (the same one seeding the session's storage scope), not a recomputed + // Index the session under the handler's workspace id (the same one + // seeding the session's storage scope), not a recomputed // `encodeWorkDirKey` — with root folding the two can diverge. - await this.appendSessionIndexEntry( - sessionId, - opts.workDir, - handle.accessor.get(ISessionContext).workspaceId, - ); + await this.appendSessionIndexEntry(sessionId, opts.workDir); } catch (error) { + // Roll back ONLY the session directory — the handler stays live. const sessionDir = handle.accessor.get(ISessionContext).sessionDir; this.sessions.delete(sessionId); await this.drainAgents(handle).catch(() => {}); @@ -178,11 +240,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async materializeSession(opts: MaterializeSessionOptions): Promise { - const workspace = await this.workspaces.createOrTouch(opts.workDir); - const workspaceId = opts.workspaceId ?? workspace.id; - const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); - const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); + const workspaceId = this.workspaceId; + const sessionScope = sessionScopeOf(this.handlerScope, opts.sessionId); + const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, opts.sessionId); const metaScope = sessionScope; + // Caller-provided dirs join the handler's SHARED in-memory set (union + // across all sessions of this workspace, §6.1) — the workspace dirs + // service owns the local.toml set and its watch; sessions read the + // combined view through the `ISessionWorkspaceInfo` seed below. Await + // the initial local.toml load first so the seed starts from the + // assembled set. + await this.workspaceDirs.ready; + await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []); const ctx: ISessionContext = { _serviceBrand: undefined, sessionId: opts.sessionId, @@ -193,12 +262,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - const localWorkspaceDirs = await this.projectLocalConfig.readAdditionalDirs(opts.workDir); - const callerAdditionalDirs = await this.projectLocalConfig.resolveAdditionalDirs( - opts.workDir, - opts.additionalDirs ?? [], - ); - const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; + const hooks = createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -207,21 +274,47 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { extra: [ ...sessionContextSeed(ctx), + ...sessionLifecycleHooksSeed(hooks), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], + // Workspace resource seeds (the §3.5 injection contracts): the + // handler's shared skill / agent-profile catalogs, AGENTS.md + // snapshot, and MCP manager reach the session as pure-data read + // views; refreshes fan out through their change events. + ...sessionSkillCatalogDataSeed(this.skillCatalog.sessionData()), + ...sessionAgentProfileCatalogSeed({ + _serviceBrand: undefined, + workspaceKey: workspaceId, + }), + ...sessionInstructionsProviderSeed(this.instructions.sessionProvider()), + ...sessionMcpHandleSeed(this.mcp.sessionHandle()), + ...sessionWorkspaceInfoSeed(this.workspaceDirs.sessionInfo()), + ...sessionToolPolicyGateSeed(this.toolPolicy.sessionGate()), + // The handler-shared Workspace-scope process runner shadows the + // Session-scope default registration in every session of this + // workspace (same seed pattern as the contracts above). + [ISessionProcessRunner, this.processRunner], ], }, ) as ISessionScopeHandle; - if (additionalDirs.length > 0) { - handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); - } try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; - void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(ISessionAgentProfileCatalog).ready; - await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); + void this.skillCatalog.ready; + await Promise.all([ + this.workspaceAgentProfileLoader.ready, + this.extraAgentProfileLoader.ready, + this.explicitAgentProfileLoader.ready, + this.userAgentProfileLoader.ready, + this.pluginAgentProfileLoader.ready, + ]); + await this.mcp.ready; } catch (error) { handle.dispose(); + // Re-arm the explicit agent-profile loader after a fatal rejection: + // its `ready` tracks the latest load pass, so without a reload the + // handler would keep rejecting every later session create even after + // the user fixes the offending agent file. + void this.explicitAgentProfileLoader.reload().catch(() => undefined); throw error; } this.sessions.set(opts.sessionId, handle); @@ -229,18 +322,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } /** - * Append one entry to the v1-compatible `session_index.jsonl`. `workspaceId` - * must be the SAME id the session was materialized with (registry-resolved, - * possibly folded from an alias spelling) — recomputing + * Append one entry to the v1-compatible `session_index.jsonl`. The entry's + * `sessionDir` derives from THIS handler's persistence scope, so the bucket + * is structurally the same workspace id the session was materialized with + * (possibly folded from an alias spelling) — recomputing * `encodeWorkDirKey(workDir)` here could mint a different bucket and orphan * the session for v1 readers. */ - private async appendSessionIndexEntry( - sessionId: string, - workDir: string, - workspaceId: string, - ): Promise { - const sessionDir = this.bootstrap.sessionDir(workspaceId, sessionId); + private async appendSessionIndexEntry(sessionId: string, workDir: string): Promise { + const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sessionId); this.appendLogStore.append('', 'session_index.jsonl', { sessionId, sessionDir, @@ -250,7 +340,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceCreated(event: SessionCreatedEvent): Promise { - await this.hooks.onDidCreateSession.run(event); + await event.handle.accessor + .get(ISessionLifecycleHooks) + .onDidCreateSession.run({ source: event.source }); this._onDidCreateSession.fire(event); event.handle.accessor .get(ITelemetryService) @@ -262,12 +354,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return this.sessions.get(sessionId); } - resume(sessionId: string): Promise { + resume(sessionId: string, opts?: ResumeSessionOptions): Promise { const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); - const promise = this.doResume(sessionId) + const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { this.telemetry .withContext({ sessionId }) @@ -281,21 +373,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return promise; } - private async doResume(sessionId: string): Promise { + private async doResume( + sessionId: string, + opts?: ResumeSessionOptions, + ): Promise { const live = this.sessions.get(sessionId); if (live !== undefined) return live; const summary = await this.index.get(sessionId); - if (summary === undefined) return undefined; - const workspace = - summary.cwd === undefined ? await this.workspaces.get(summary.workspaceId) : undefined; - const workDir = summary.cwd ?? workspace?.root; - if (workDir === undefined) return undefined; + // A handler resumes only its own workspace's sessions. + if (summary === undefined || summary.workspaceId !== this.workspaceId) return undefined; + const workDir = summary.cwd ?? this.workspaceContext.cwd; const handle = await this.materializeSession({ sessionId, workDir, - workspaceId: summary.workspaceId, + additionalDirs: opts?.additionalDirs, }); const agents = handle.accessor.get(IAgentLifecycleService); if (agents.get(MAIN_AGENT_ID) === undefined) { @@ -347,7 +440,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceWillClose(event: SessionWillCloseEvent): Promise { - await this.hooks.onWillCloseSession.run(event); + await event.handle.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.run({ reason: event.reason }); } private async drainAgents(handle: ISessionScopeHandle): Promise { @@ -362,13 +457,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); - if (sourceHandle === undefined && indexSummary === undefined) { + if ( + (sourceHandle === undefined && indexSummary === undefined) || + (indexSummary !== undefined && indexSummary.workspaceId !== this.workspaceId) + ) { + // Fork never crosses handlers: the source must live in this workspace. throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sourceId} does not exist`); } - const workspaceId = - sourceHandle !== undefined - ? sourceHandle.accessor.get(ISessionContext).workspaceId - : indexSummary!.workspaceId; // Fork is unconditional — it never rejects on the source being busy. // Copying a live journal yields a torn prefix (a turn cut mid-flight), @@ -381,15 +476,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { - const workspace = await this.workspaces.get(workspaceId); - if (workspace === undefined) { - throw new Error2(ErrorCodes.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`); - } - const sourceMeta = sourceHandle !== undefined ? await sourceHandle.accessor.get(ISessionMetadata).read() - : await this.readMetaFromDisk(workspaceId, sourceId); + : await this.readMetaFromDisk(sourceId); targetId = opts.newSessionId ?? createSessionId(); if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { @@ -399,15 +489,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - targetSessionDir = this.bootstrap.sessionDir(workspaceId, targetId); + targetSessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, targetId); await this.copySessionFiles( - this.bootstrap.sessionDir(workspaceId, sourceId), + sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sourceId), targetSessionDir, ); target = await this.materializeSession({ sessionId: targetId, - workDir: workspace.root, + workDir: this.workspaceContext.cwd, }); const targetCtx = target.accessor.get(ISessionContext); const targetMeta = target.accessor.get(ISessionMetadata); @@ -417,10 +507,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec for (const agentId of agentIds) { await this.copyAgentWire({ sourceHandle, - sourceWorkspaceId: workspaceId, sourceSessionId: sourceId, agentId, - targetWorkspaceId: targetCtx.workspaceId, targetSessionId: targetCtx.sessionId, }); } @@ -435,7 +523,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - await this.duplicateCronTasks(workspaceId, sourceId, targetId); + await this.duplicateCronTasks(sourceId, targetId); for (const agentId of agentIds) { const sourceAgent = sourceAgents[agentId]!; @@ -446,7 +534,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - await this.appendSessionIndexEntry(targetId, workspace.root, targetCtx.workspaceId); + await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, sessionId: targetId, @@ -498,10 +586,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async copyAgentWire(args: { readonly sourceHandle: ISessionScopeHandle | undefined; - readonly sourceWorkspaceId: string; readonly sourceSessionId: string; readonly agentId: string; - readonly targetWorkspaceId: string; readonly targetSessionId: string; }): Promise { if (args.sourceHandle !== undefined) { @@ -515,11 +601,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const records = await collect( this.appendLogStore.read( - this.bootstrap.agentScope( - args.sourceWorkspaceId, - args.sourceSessionId, - args.agentId, - ), + agentScopeOf(sessionScopeOf(this.handlerScope, args.sourceSessionId), args.agentId), AGENT_WIRE_RECORD_KEY, ), ); @@ -531,11 +613,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec records.push(forkedRecord()); await this.appendLogStore.rewrite( - this.bootstrap.agentScope( - args.targetWorkspaceId, - args.targetSessionId, - args.agentId, - ), + agentScopeOf(sessionScopeOf(this.handlerScope, args.targetSessionId), args.agentId), AGENT_WIRE_RECORD_KEY, records, ); @@ -584,12 +662,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } - private async duplicateCronTasks( - workspaceId: string, - sourceId: string, - targetId: string, - ): Promise { - const tasks = await this.cronStore.list({ workspaceId }); + private async duplicateCronTasks(sourceId: string, targetId: string): Promise { + const tasks = await this.cronStore.list({ workspaceId: this.workspaceId }); for (const task of tasks) { if (task.tags?.[CRON_SESSION_TAG] !== sourceId) continue; const clone: CronTask = { @@ -597,27 +671,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec id: ulid(), tags: { ...task.tags, [CRON_SESSION_TAG]: targetId }, }; - await this.cronStore.save(workspaceId, clone); + await this.cronStore.save(this.workspaceId, clone); } } - private async readMetaFromDisk( - workspaceId: string, - sessionId: string, - ): Promise { - return this.docs.get( - this.bootstrap.sessionScope(workspaceId, sessionId), - 'state.json', - ); + private async readMetaFromDisk(sessionId: string): Promise { + return this.docs.get(sessionScopeOf(this.handlerScope, sessionId), 'state.json'); } } registerScopedService( - LifecycleScope.App, - ISessionLifecycleService, - SessionLifecycleService, + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, ScopeActivation.OnScopeCreated, - 'sessionLifecycle', + 'workspaceHandler', ); async function collect(iterable: AsyncIterable): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts new file mode 100644 index 000000000..d88aab823 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts @@ -0,0 +1,36 @@ +/** + * `workspaceInstructions` domain (L4) — Workspace-scoped AGENTS.md service + * contract. + * + * Defines `IWorkspaceInstructionsService`, the handler-level owner of the + * workspace's AGENTS.md instruction snapshot: loaded once at handler + * materialization through the `profile` domain's pure loader, then + * invalidated by fs watch on every candidate instruction file and reloaded + * debounced. `sessionProvider()` projects the snapshot into the + * `ISessionInstructionsProvider` seed every Session scope of this handler + * receives, so agent system prompts read the shared snapshot and refresh off + * its change event instead of re-reading the files per prompt build. Bound + * at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; + +export interface WorkspaceInstructionsSnapshot { + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; +} + +export interface IWorkspaceInstructionsService { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly snapshot: WorkspaceInstructionsSnapshot; + readonly onDidChange: Event; + reload(): Promise; + sessionProvider(): ISessionInstructionsProvider; +} + +export const IWorkspaceInstructionsService: ServiceIdentifier = + createDecorator('workspaceInstructionsService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts new file mode 100644 index 000000000..91962bbd7 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -0,0 +1,148 @@ +/** + * `workspaceInstructions` domain (L4) — `IWorkspaceInstructionsService` + * implementation. + * + * Loads the workspace root's AGENTS.md hierarchy at construction through the + * `profile` domain's pure loader (over the os `hostFs`, the host home dir, + * and the `bootstrap` brand dir), then watches the loader's probe set + * (`agentsMdWatchRoots` — brand / user-generic / project-root→leaf chain, + * each plan root watched recursively and pruned to its candidates so files + * created later inside not-yet-existing directories are still caught) + * through `hostFsWatch` and reloads debounced; the change event fires only + * when the combined content or warning actually changed. The snapshot is shared by every session of + * the handler through the `ISessionInstructionsProvider` seed + * (`sessionProvider()`), a live read view over this service. Bound at + * Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { agentsMdWatchRoots, loadAgentsMdForRoots } from '#/agent/profile/context'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { + IWorkspaceInstructionsService, + type WorkspaceInstructionsSnapshot, +} from './workspaceInstructions'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceInstructionsService + extends Disposable + implements IWorkspaceInstructionsService +{ + declare readonly _serviceBrand: undefined; + + private current: WorkspaceInstructionsSnapshot = { + agentsMd: undefined, + agentsMdWarning: undefined, + }; + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private reloadTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IHostEnvironment private readonly env: IHostEnvironment, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.ready = this.reload(); + void this.watchCandidateFiles(); + } + + get snapshot(): WorkspaceInstructionsSnapshot { + return this.current; + } + + reload(): Promise { + const tail = this.reloadTail.catch(() => undefined).then(async () => { + const result = await loadAgentsMdForRoots( + { fs: this.fs, homeDir: this.env.homeDir }, + this.bootstrap.homeDir, + [this.workspace.cwd], + ); + const next: WorkspaceInstructionsSnapshot = { + agentsMd: result.content, + agentsMdWarning: result.warning, + }; + if ( + next.agentsMd !== this.current.agentsMd || + next.agentsMdWarning !== this.current.agentsMdWarning + ) { + this.current = next; + this.onDidChangeEmitter.fire(); + } + }); + this.reloadTail = tail; + return tail; + } + + sessionProvider(): ISessionInstructionsProvider { + const currentAgentsMd = (): string | undefined => this.current.agentsMd; + const currentWarning = (): string | undefined => this.current.agentsMdWarning; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get agentsMd() { + return currentAgentsMd(); + }, + get agentsMdWarning() { + return currentWarning(); + }, + }; + } + + private async watchCandidateFiles(): Promise { + // Watch each plan root recursively, pruned to its candidate files: + // watching a candidate file directly never fires when its parent + // directory (`.kimi-code` / `.agents`) does not exist yet either. + const plan = await agentsMdWatchRoots( + { fs: this.fs, homeDir: this.env.homeDir }, + this.workspace.cwd, + this.bootstrap.homeDir, + ); + for (const { root, candidates } of plan) { + try { + const handle = this.fsWatch.watch(root, { + ignored: subtreeWatchFilter(root, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.reload().catch((error) => { + this.log.warn(`AGENTS.md reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch instruction root ${root}: ${String(error)}`); + } + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceInstructionsService, + WorkspaceInstructionsService, + ScopeActivation.OnScopeCreated, + 'workspaceInstructions', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts new file mode 100644 index 000000000..615bf5ab0 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts @@ -0,0 +1,39 @@ +/** + * `workspaceMcp` domain (L5) — Workspace-scoped MCP subsystem contract. + * + * Defines `IWorkspaceMcpService`, the handler-level owner of the workspace's + * ONE shared `McpConnectionManager`: connected at handler materialization + * from the `workspaceMcpConfig` domain's effective server snapshot and + * incrementally reconciled as its change events arrive. Sessions cannot + * contribute MCP servers: there is no caller-supplied server channel on + * session create/resume. Every session of the handler receives the manager + * through the `ISessionMcpHandle` seed (`sessionHandle()`); no per-session + * MCP connections exist. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; + +export interface IWorkspaceMcpService { + readonly _serviceBrand: undefined; + + /** + * The handler's initial connect (the config domain's effective snapshot). + * Settles once the initial connection attempt finishes; per-server failures + * are reflected in MCP status entries rather than rejecting this promise. + */ + readonly ready: Promise; + + /** + * The handler's shared connection manager. Always available, independent + * of the initial connect's progress; global timeout defaults come from the + * config domain's tunables at each (re)connect. + */ + connectionManager(): McpConnectionManager; + + sessionHandle(): ISessionMcpHandle; +} + +export const IWorkspaceMcpService: ServiceIdentifier = + createDecorator('workspaceMcpService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts new file mode 100644 index 000000000..cb48c8ba4 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -0,0 +1,143 @@ +/** + * `workspaceMcp` domain (L5) — `IWorkspaceMcpService` implementation. + * + * Owns the handler-wide `McpConnectionManager` (built at construction, + * shared by every session of the workspace) and nothing else: where the + * server set comes from is the `workspaceMcpConfig` domain's business. This + * service drives the initial connect from the config domain's snapshot, + * applies its reconciled change events incrementally (serialized on a + * mutation tail, always after the initial connect settles), feeds the + * manager's global timeout defaults from the config domain's tunables at + * each (re)connect, and reports connection telemetry for the initial load. + * An outright initial-load or change-apply failure is logged (per-server + * failures are status entries). The manager (and its stdio child processes, + * whose cwd is the handler root) lives as long as the handler — i.e. the + * process — so a stateful stdio server is shared by concurrent sessions of + * the workspace rather than owned by one session. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; + +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; + +import { IWorkspaceMcpService } from './workspaceMcp'; + +export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpService { + declare readonly _serviceBrand: undefined; + + private readonly manager: McpConnectionManager; + readonly ready: Promise; + /** Serializes change applications against each other. */ + private mutationTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceMcpConfigService private readonly mcpConfig: IWorkspaceMcpConfigService, + @IMcpOAuthStore oauthStore: IMcpOAuthStore, + @ILogService private readonly log: ILogService, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) { + super(); + const oauthService = new McpOAuthService({ store: oauthStore }); + this.manager = new McpConnectionManager({ + log: this.log, + oauthService, + stdioCwd: workspace.cwd, + resolveDefaultTimeouts: () => this.mcpConfig.tunables(), + }); + this._register({ dispose: () => void this.manager.shutdown() }); + this._register( + this.mcpConfig.onDidChange((change) => { + this.scheduleApply(change); + }), + ); + this.ready = this.initialize().catch((error: unknown) => { + this.log.error('mcp initial load failed', { error }); + }); + } + + connectionManager(): McpConnectionManager { + return this.manager; + } + + sessionHandle(): ISessionMcpHandle { + return { + _serviceBrand: undefined, + ready: this.ready, + connectionManager: this.manager, + }; + } + + private mutate(work: () => Promise): Promise { + const tail = this.mutationTail.catch(() => undefined).then(work); + this.mutationTail = tail; + return tail; + } + + private async initialize(): Promise { + await this.mcpConfig.ready; + const servers = this.mcpConfig.servers(); + if (Object.keys(servers).length === 0) return; + await this.manager.connectAll(servers); + this.trackMcpInitialLoad(); + } + + /** Applies a change after the initial connect settles, serialized. */ + private scheduleApply(change: McpServersChange): void { + void this.ready + .then(() => this.mutate(() => this.apply(change))) + .catch((error) => { + this.log.warn(`mcp server change apply failed: ${String(error)}`); + }); + } + + private async apply(change: McpServersChange): Promise { + for (const name of change.remove) { + await this.manager.remove(name); + } + for (const [name, config] of Object.entries(change.upsert)) { + await this.manager.connect(name, config); + } + } + + private trackMcpInitialLoad(): void { + const entries = this.manager.list().filter((entry) => entry.status !== 'disabled'); + const totalCount = entries.length; + if (totalCount === 0) return; + + const connectedCount = entries.filter((entry) => entry.status === 'connected').length; + if (connectedCount > 0) { + this.telemetry.track2('mcp_connected', { + server_count: connectedCount, + total_count: totalCount, + }); + } + + const failedCount = entries.filter((entry) => entry.status === 'failed').length; + if (failedCount > 0) { + this.telemetry.track2('mcp_failed', { + failed_count: failedCount, + total_count: totalCount, + }); + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpService, + WorkspaceMcpService, + ScopeActivation.OnScopeCreated, + 'workspaceMcp', +); diff --git a/packages/agent-core-v2/src/agent/mcp/config-loader.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts similarity index 62% rename from packages/agent-core-v2/src/agent/mcp/config-loader.ts rename to packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts index 2aef7d148..50d536f41 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-loader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts @@ -1,8 +1,25 @@ -import { readFile, stat } from 'node:fs/promises'; +/** + * `workspaceMcpConfig` domain (L5) — MCP JSON config discovery and loading. + * + * Resolves the three MCP config files for a cwd (user `mcp.json` under the + * kimi home, project-root `.mcp.json` — the root discovered through the + * `git` domain's work-tree probe — and `.kimi-code/mcp.json` under the cwd) + * and loads them with user < project-root < project precedence, normalizing + * relative stdio `cwd` entries against the project-root file's directory. + * `includeProject: false` skips the two project-level files and loads the + * user file only — the workspace-trust gate: the project files ship with + * the checkout, so an untrusted workspace must never see them. All + * filesystem access goes through the os `IHostFileSystem`, supplied by + * the caller. Pure functions — no scoped state. + */ + import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; +import { findGitWorkTree } from '#/app/git/workTree'; import { resolveKimiHome } from '#/app/bootstrap/bootstrap'; -import { McpServerConfigSchema, type McpServerConfig } from './config-schema'; +import { OsFsErrors, HostFsError } from '#/os/interface/hostFsErrors'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; import { ErrorCodes, Error2 } from '#/errors'; import { z } from 'zod'; @@ -17,12 +34,14 @@ export interface McpJsonPaths { } export interface ResolveMcpJsonPathsInput { + readonly fs: IHostFileSystem; readonly cwd: string; readonly homeDir?: string; } export async function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): Promise { - const projectRoot = await findProjectRoot(input.cwd); + const start = normalize(input.cwd); + const projectRoot = (await findGitWorkTree(input.fs, start))?.root ?? start; return { user: join(resolveKimiHome(input.homeDir), 'mcp.json'), @@ -32,55 +51,40 @@ export async function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): Prom } export interface LoadMcpServersInput { + readonly fs: IHostFileSystem; readonly cwd: string; readonly homeDir?: string; + /** Defaults to true; false loads the user file only (untrusted workspace). */ + readonly includeProject?: boolean; } export async function loadMcpServers( input: LoadMcpServersInput, ): Promise> { - const paths = await resolveMcpJsonPaths({ cwd: input.cwd, homeDir: input.homeDir }); + const paths = await resolveMcpJsonPaths(input); + if (input.includeProject === false) { + return readMcpJson(input.fs, paths.user); + } const [user, projectRoot, project] = await Promise.all([ - readMcpJson(paths.user), - readMcpJson(paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }), - readMcpJson(paths.project), + readMcpJson(input.fs, paths.user), + readMcpJson(input.fs, paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }), + readMcpJson(input.fs, paths.project), ]); return { ...user, ...projectRoot, ...project }; } -async function findProjectRoot(cwd: string): Promise { - const start = normalize(cwd); - let current = start; - - while (true) { - if (await pathExists(join(current, '.git'))) return current; - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } -} - -async function pathExists(filePath: string): Promise { - try { - await stat(filePath); - return true; - } catch (error: unknown) { - if (isPathMissing(error)) return false; - throw error; - } -} - interface ReadMcpJsonOptions { readonly stdioCwdBase?: string; } async function readMcpJson( + fs: IHostFileSystem, filePath: string, options: ReadMcpJsonOptions = {}, ): Promise> { let text: string; try { - text = await readFile(filePath, 'utf-8'); + text = await fs.readText(filePath); } catch (error: unknown) { if (isFileNotFound(error)) return {}; throw new Error2(ErrorCodes.CONFIG_INVALID, `Failed to read ${filePath}: ${describeError(error)}`, { @@ -131,17 +135,7 @@ function resolvePath(base: string, value: string): string { } function isFileNotFound(error: unknown): boolean { - return getErrorCode(error) === 'ENOENT'; -} - -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; + return error instanceof HostFsError && error.code === OsFsErrors.codes.OS_FS_NOT_FOUND; } function describeError(error: unknown): string { diff --git a/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts new file mode 100644 index 000000000..96cfac0b0 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts @@ -0,0 +1,76 @@ +/** + * `workspaceMcpConfig` domain (L5) — Workspace-scoped MCP server-config owner + * contract. + * + * Defines `IWorkspaceMcpConfigService`, the single source of truth for "which + * MCP servers should this workspace run": it resolves the MCP config files + * (user `mcp.json`, project-root `.mcp.json`, `.kimi-code/mcp.json`) and the + * enabled plugins' contributions — on a name collision the file config wins — + * with the two project-level files gated by `workspaceTrust` (an untrusted + * workspace gets the user file and plugin contributions only), then tracks + * both sources (fs watch on the config files, + * `plugins.onDidReload`) and publishes the reconciled effective set as a + * snapshot plus already-diffed change events. Consumers never read config + * files, the plugin registry, or the `[mcp]` config section themselves: the + * global timeout preferences are exposed here as {@link tunables} too, so the + * connection side has exactly one configuration dependency. The domain holds + * no connection state and never talks to an MCP server; writing config files + * stays out of the engine (the node-sdk global store and the `/mcp-config` + * skill are the external write paths). Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +/** + * One reconciled change of the effective server set. `upsert` entries are new + * or fingerprint-changed and ask the consumer to (re)connect; `remove` names + * vanished from the effective set and ask it to disconnect. Entries whose + * merged value did not actually change (e.g. a same-named fallback with an + * identical config) produce no event. + */ +export interface McpServersChange { + readonly upsert: Readonly>; + readonly remove: readonly string[]; +} + +/** The global `[mcp]` timeout preferences, as resolved by `config`. */ +export interface McpTunables { + readonly startupTimeoutMs?: number; + readonly toolTimeoutMs?: number; +} + +export interface IWorkspaceMcpConfigService { + readonly _serviceBrand: undefined; + + /** + * The initial resolve (config files + plugin contributions). Settles once + * the first effective snapshot is available; a load failure is logged and + * resolves to an empty snapshot rather than rejecting. + */ + readonly ready: Promise; + + /** + * The current effective server set (file sources win name collisions). + * Meaningful after {@link ready}; immutable — replaced wholesale on change. + */ + servers(): Readonly>; + + /** + * The global `[mcp]` timeout preferences, resolved live from `config` at + * each call so a mid-session edit applies to the next (re)connect. + */ + tunables(): McpTunables; + + /** + * Fires when the effective set changes after the initial resolve (a + * watched config file changed, or plugins reloaded). Never fires before + * {@link ready} settles; empty diffs are suppressed. + */ + readonly onDidChange: Event; +} + +export const IWorkspaceMcpConfigService: ServiceIdentifier = + createDecorator('workspaceMcpConfigService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts new file mode 100644 index 000000000..f732f2ef3 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts @@ -0,0 +1,252 @@ +/** + * `workspaceMcpConfig` domain (L5) — `IWorkspaceMcpConfigService` + * implementation. + * + * Resolves the handler's effective MCP server set from exactly two sources — + * the MCP config files (`resolveMcpJsonPaths`: user `mcp.json`, project-root + * `.mcp.json`, `.kimi-code/mcp.json` — read through the os `hostFs`) and the + * enabled plugins; on a name collision the file config wins, and when one + * source's server vanishes the same-named entry from the other source takes + * over. The two project-level files are gated by `workspaceTrust`: while the + * workspace is untrusted they are skipped (the user file and plugin + * contributions still load), and a trust flip triggers the same reload path + * as a file edit, so trusting connects the project servers and untrusting + * drops them. The config files are watched (the user file directly, the + * project root recursively pruned to the two project candidates) and plugin + * contributions follow `plugins.onDidReload`; every re-resolve recomputes the + * merged view and publishes the fingerprint diff through `onDidChange`, so a + * config edit or a plugin installed, enabled or reloaded AFTER the handler + * materialized still reaches the connection side. Reloads are debounced and + * serialized on a mutation tail; an outright initial-load or reload failure + * is logged, leaving the last published snapshot in place. The initial + * resolve waits for `config.ready` so the file/plugin read and the `[mcp]` + * section read are deterministic. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { dirname } from 'pathe'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; + +import { loadMcpServers, resolveMcpJsonPaths } from './config-loader'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, + type McpTunables, +} from './workspaceMcpConfig'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceMcpConfigService extends Disposable implements IWorkspaceMcpConfigService { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + /** Serializes watch/plugin reloads against each other. */ + private mutationTail: Promise = Promise.resolve(); + /** File-sourced server configs by name (the winning source on collisions). */ + private fileServers = new Map(); + /** Plugin-sourced server configs by name. */ + private pluginServers = new Map(); + /** The last published merged view; replaced wholesale on every change. */ + private current: Readonly> = {}; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange = this.changeEmitter.event; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IPluginService private readonly plugins: IPluginService, + @ILogService private readonly log: ILogService, + @IConfigService private readonly config: IConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IWorkspaceTrust private readonly trust: IWorkspaceTrust, + ) { + super(); + this.ready = this.initialize().catch((error: unknown) => { + this.log.error('mcp config initial load failed', { error }); + }); + this._register( + this.plugins.onDidReload(() => { + void this.reloadPluginServers().catch((error) => { + this.log.warn(`mcp plugin reload failed: ${String(error)}`); + }); + }), + ); + this._register( + this.trust.onDidChange(() => { + void this.reloadFileServers().catch((error) => { + this.log.warn(`mcp trust reload failed: ${String(error)}`); + }); + }), + ); + void this.watchConfigFiles(); + } + + servers(): Readonly> { + return this.current; + } + + tunables(): McpTunables { + const section = this.config.get(MCP_SECTION); + return { + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }; + } + + private mutate(work: () => Promise): Promise { + const tail = this.mutationTail.catch(() => undefined).then(work); + this.mutationTail = tail; + return tail; + } + + private async initialize(): Promise { + await this.config.ready; + await this.trust.ready; + const [fileServers, pluginServers] = await Promise.all([ + loadMcpServers({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + includeProject: this.trust.isTrusted(), + }), + this.plugins.enabledMcpServers(), + ]); + this.fileServers = new Map(Object.entries(fileServers)); + this.pluginServers = new Map(Object.entries(pluginServers)); + this.current = this.merged(); + } + + /** The effective set: file config wins on a name collision. */ + private merged(): Record { + return { ...Object.fromEntries(this.pluginServers), ...Object.fromEntries(this.fileServers) }; + } + + private async watchConfigFiles(): Promise { + const paths = await resolveMcpJsonPaths({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + }); + // The user file's parent (the kimi home) always exists, so a direct + // watch is reliable; the project candidates' parents may not exist yet, + // so the project root is watched recursively and pruned to them. + this.watchPaths([paths.user]); + const projectRoot = dirname(paths.projectRoot); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, [paths.projectRoot, paths.project]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.scheduleFileReload(); + }), + ); + } + + private watchPaths(paths: readonly string[]): void { + for (const path of paths) { + const handle = this.fsWatch.watch(path); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.scheduleFileReload(); + }), + ); + } + } + + private scheduleFileReload(): void { + this.watchDebounce.cancelAndSet(() => { + void this.reloadFileServers().catch((error) => { + this.log.warn(`mcp config reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + } + + private async reloadFileServers(): Promise { + await this.ready; + await this.mutate(async () => { + const fresh = await loadMcpServers({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + includeProject: this.trust.isTrusted(), + }); + this.fileServers = new Map(Object.entries(fresh)); + this.publishIfChanged(); + }); + } + + private async reloadPluginServers(): Promise { + await this.ready; + await this.mutate(async () => { + const fresh = await this.plugins.enabledMcpServers(); + this.pluginServers = new Map(Object.entries(fresh)); + this.publishIfChanged(); + }); + } + + /** + * Recomputes the merged view against the last published snapshot and fires + * the fingerprint diff; a change that leaves the effective set untouched + * (e.g. a vanished source shadowed by an identical same-named entry) + * produces no event. + */ + private publishIfChanged(): void { + const next = this.merged(); + const upsert: Record = {}; + const remove: string[] = []; + for (const [name, config] of Object.entries(next)) { + const previous = this.current[name]; + if (previous === undefined || fingerprintConfig(previous) !== fingerprintConfig(config)) { + upsert[name] = config; + } + } + for (const name of Object.keys(this.current)) { + if (!Object.hasOwn(next, name)) remove.push(name); + } + this.current = next; + if (Object.keys(upsert).length === 0 && remove.length === 0) return; + this.changeEmitter.fire({ upsert, remove }); + } +} + +function fingerprintConfig(config: McpServerConfig): string { + return JSON.stringify(sortKeysDeep(config)); +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, sortKeysDeep(nested)]), + ); + } + return value; +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpConfigService, + WorkspaceMcpConfigService, + ScopeActivation.OnScopeCreated, + 'workspaceMcpConfig', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts new file mode 100644 index 000000000..c2329f7f5 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts @@ -0,0 +1,66 @@ +/** + * `workspaceProcess` domain (L2) — `ISessionProcessRunner` implementation. + * + * Resolves the default cwd from the handler's `IWorkspaceContext` (chdir is + * gone, so the workspace root is the one fixed default) and delegates the + * actual host spawn to the App-scope `IHostProcessService`. A per-call + * `options.cwd` wins over the handler root. A per-call `options.env` is + * overlaid onto `process.env` and passed as the child's complete env bag (the + * host replaces the child env with what we pass); when `options.env` is + * omitted we pass `undefined` so the child inherits `process.env` verbatim. + * + * Bound at Workspace scope — one runner per handler, shared by every session + * of the workspace. The contract (`ISessionProcessRunner`) stays in the + * session domain so Session/Agent consumers keep importing it without + * crossing the Workspace-tier import ban; the Workspace-scope registration + * reaches them through ordinary parent-scope resolution. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from '#/session/process/processRunner'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +export class WorkspaceProcessRunnerService implements ISessionProcessRunner { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceContext private readonly ctx: IWorkspaceContext, + @IHostProcessService private readonly hostProcess: IHostProcessService, + ) {} + + async exec(args: readonly string[], options?: ProcessExecOptions): Promise { + const command = args[0]; + if (command === undefined) { + throw new Error( + 'WorkspaceProcessRunnerService.exec(): at least one argument (the command to run) is required.', + ); + } + const restArgs = args.slice(1); + + const cwd = options?.cwd ?? this.ctx.cwd; + const env = this._buildExecEnv(options?.env); + + return this.hostProcess.spawn(command, restArgs, { cwd, env }); + } + + private _buildExecEnv( + invocationEnv: Record | undefined, + ): Record | undefined { + if (invocationEnv === undefined) { + return undefined; + } + return { + ...(process.env as Record), + ...invocationEnv, + }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + ISessionProcessRunner, + WorkspaceProcessRunnerService, + ScopeActivation.OnScopeCreated, + 'workspaceProcess', +); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts similarity index 72% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts index d2096c744..9966d98a6 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts @@ -1,11 +1,11 @@ /** - * `sessionSkillCatalog` domain (L3) — explicit `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — explicit `ISkillSource` producer. * * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this * source contributes those directories as the user source, resolving relative - * paths against the session project root. When no explicit dirs are configured, - * it yields nothing so default user / project discovery remains active. Bound at - * Session scope so each session resolves paths against its own workDir. + * paths against the workspace root. When no explicit dirs are configured, + * it yields nothing so default user / project discovery remains active. Bound + * at Workspace scope so every session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -14,8 +14,12 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; export interface IExplicitFileSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -33,7 +37,7 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource { constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, ) {} @@ -43,15 +47,15 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource { return { skills: [] }; } return this.discovery.discover( - await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'), + await configuredRoots(explicitDirs, this.workspace.cwd, this.bootstrap.osHomeDir, 'user'), ); } } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts similarity index 72% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts index 6d8d834c3..f9588a297 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts @@ -1,11 +1,13 @@ /** - * `sessionSkillCatalog` domain (L3) — extra `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — extra `ISkillSource` producer. * * Discovers user-configured extra skill directories (`extraSkillDirs`) through * `ISkillDiscovery`, contributing them at priority 10 (above plugin / builtin, - * below user / workspace). Relative paths resolve against the session project - * root; `~` and `~/...` resolve against the bootstrap home dir. Bound at Session - * scope so each session reads its own workspace root. + * below user / workspace). Relative paths resolve against the workspace root; + * `~` and `~/...` resolve against the bootstrap home dir. Re-fires + * `onDidChange` when the `extraSkillDirs` config section changes so the + * catalog re-scans THIS source only. Bound at Workspace scope so every + * session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -20,8 +22,12 @@ import { } from '#/app/skillCatalog/configSection'; import { configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; export interface IExtraFileSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -41,7 +47,7 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, ) { super(); @@ -56,15 +62,15 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS await this.config.ready; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; return this.discovery.discover( - await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), + await configuredRoots(extraSkillDirs, this.workspace.cwd, this.bootstrap.osHomeDir, 'extra'), ); } } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts similarity index 76% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts index 36e021ab3..0988d7c36 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts @@ -1,20 +1,24 @@ /** - * `sessionSkillCatalog` domain (L3) — plugin `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — plugin `ISkillSource` producer. * * Discovers skills contributed by enabled plugins through `ISkillDiscovery` * (roots from `plugin.pluginSkillRoots()`), contributing them at priority 5 * (above builtin, below extra / user / workspace, so project, user and extra * skills win name collisions). Re-emits `plugin.onDidReload` as `onDidChange` - * so the sink re-pulls plugin skills when plugins reload; install / enable / - * remove mutations deliberately do not refresh the session catalog — those - * take effect on the next explicit reload. Bound at Session scope. + * so the catalog re-pulls plugin skills when plugins reload. Bound at + * Workspace scope so every session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { + PLUGIN_SKILL_SOURCE_ID, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; import { IPluginService } from '#/app/plugin/plugin'; export interface IPluginSkillSource extends ISkillSource { @@ -24,7 +28,7 @@ export interface IPluginSkillSource extends ISkillSource { export const IPluginSkillSource: ServiceIdentifier = createDecorator('pluginSkillSource'); -export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; +export { PLUGIN_SKILL_SOURCE_ID }; export class PluginSkillSource implements IPluginSkillSource { declare readonly _serviceBrand: undefined; @@ -49,9 +53,9 @@ export class PluginSkillSource implements IPluginSkillSource { } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts new file mode 100644 index 000000000..ecb98c6ed --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts @@ -0,0 +1,115 @@ +/** + * `workspaceSkillCatalog` domain (L3) — workspace-root `ISkillSource` + * producer. + * + * Discovers project skills from the handler's workspace root + * (`workspaceContext.cwd`) through `ISkillDiscovery`, contributing them at + * priority 30 (above user / extra / plugin / builtin). Watches the project + * skill-root candidates (`.kimi-code/skills`, `.agents/skills` under the + * project root, watched whether or not they exist yet) through + * `hostFsWatch` and re-fires `onDidChange` debounced, so the catalog + * re-scans THIS source only when project skill files change. Renamed from + * the Session-scope `IWorkspaceFileSkillSource` (now + * `IWorkspaceRootSkillSource`) to avoid the collision called out in the + * workspace-domain plan. Bound at Workspace scope so every session of the + * handler shares one scan. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { IConfigService } from '#/app/config/config'; +import { + MERGE_ALL_AVAILABLE_SKILLS_SECTION, + type MergeAllAvailableSkillsConfig, +} from '#/app/skillCatalog/configSection'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { projectRoots, projectSkillRootCandidates } from '#/app/skillCatalog/skillRoots'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +export const WORKSPACE_ROOT_SKILL_SOURCE_ID = 'workspace'; + +const WATCH_DEBOUNCE_MS = 200; + +export interface IWorkspaceRootSkillSource extends ISkillSource { + readonly _serviceBrand: undefined; +} + +export const IWorkspaceRootSkillSource: ServiceIdentifier = + createDecorator('workspaceRootSkillSource'); + +export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRootSkillSource { + declare readonly _serviceBrand: undefined; + + readonly id = WORKSPACE_ROOT_SKILL_SOURCE_ID; + readonly priority = SKILL_SOURCE_PRIORITY.workspace; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly watchReady: Promise; + + constructor( + @ISkillDiscovery private readonly discovery: ISkillDiscovery, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IConfigService private readonly config: IConfigService, + @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + ) { + super(); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); + }), + ); + this.watchReady = this.watchProjectSkillRoots(); + } + + async load(): Promise { + // The watch attaches before the first scan returns, so a change landing + // right after the scan cannot slip between the two. + await this.watchReady; + if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { + return { skills: [] }; + } + await this.config.ready; + const mergeAllAvailableSkills = + this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + return this.discovery.discover( + await projectRoots(this.workspace.cwd, { mergeAllAvailableSkills }), + ); + } + + private async watchProjectSkillRoots(): Promise { + // Watch the project root recursively, pruned to the skill-root + // candidates: watching a candidate directory directly never fires when + // its parent (`.kimi-code` / `.agents`) does not exist yet either. + const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => this.onDidChangeEmitter.fire(), WATCH_DEBOUNCE_MS); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceRootSkillSource, + WorkspaceRootSkillSource, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts new file mode 100644 index 000000000..87043d209 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts @@ -0,0 +1,33 @@ +/** + * `workspaceSkillCatalog` domain (L3) — Workspace-scoped skill catalog + * contract. + * + * Defines `IWorkspaceSkillCatalog`, the handler-level owner of skill + * discovery and merging: at handler materialization it loads every source + * (builtin / user / explicit / extra / workspace-root / plugin) and merges by + * priority; afterwards single sources refresh incrementally (fs watch on the + * project skill dirs, config section changes, plugin reloads) — never a full + * rescan. `sessionData()` projects the merged view into the + * `ISessionSkillCatalogData` seed every Session scope of this handler + * receives. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; + +export interface IWorkspaceSkillCatalog { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly catalog: SkillCatalog; + readonly onDidChange: Event; + load(): Promise; + reload(): Promise; + sessionData(): ISessionSkillCatalogData; +} + +export const IWorkspaceSkillCatalog: ServiceIdentifier = + createDecorator('workspaceSkillCatalog'); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts new file mode 100644 index 000000000..148c1bb53 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -0,0 +1,144 @@ +/** + * `workspaceSkillCatalog` domain (L3) — `IWorkspaceSkillCatalog` + * implementation. + * + * Merges builtin, user, explicit, extra, workspace-root, and plugin skill + * sources by priority ONCE per handler, serializing refreshes for each + * source; afterwards a source's `onDidChange` (fs watch / config section / + * plugin reload) re-scans that source alone and re-fires the merged change + * event — no full rescan ever leaves the build-time load. The merged view is + * shared by every session of the handler through the + * `ISessionSkillCatalogData` seed (`sessionData()`), a live read view over + * this service. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; + +import { IExplicitFileSkillSource } from './explicitFileSkillSource'; +import { IExtraFileSkillSource } from './extraFileSkillSource'; +import { IPluginSkillSource } from './pluginSkillSource'; +import { IWorkspaceRootSkillSource } from './rootFileSkillSource'; +import { IWorkspaceSkillCatalog } from './workspaceSkillCatalog'; + +export class WorkspaceSkillCatalogService extends Disposable implements IWorkspaceSkillCatalog { + declare readonly _serviceBrand: undefined; + + private readonly sources: readonly ISkillSource[]; + private readonly contributions = new Map< + string, + { readonly c: SkillContribution; readonly priority: number } + >(); + private merged = new InMemorySkillCatalog(); + private readonly sourceLoadTails = new Map>(); + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + @IBuiltinSkillSource builtin: IBuiltinSkillSource, + @IUserFileSkillSource user: IUserFileSkillSource, + @IExplicitFileSkillSource explicit: IExplicitFileSkillSource, + @IExtraFileSkillSource extra: IExtraFileSkillSource, + @IWorkspaceRootSkillSource workspace: IWorkspaceRootSkillSource, + @IPluginSkillSource plugin: IPluginSkillSource, + ) { + super(); + this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted( + (a, b) => a.priority - b.priority, + ); + for (const s of this.sources) { + if (s.onDidChange) + this._register( + s.onDidChange(() => { + void this.reloadSource(s.id); + }), + ); + } + this.ready = this.loadAll(); + } + + get catalog(): SkillCatalog { + return this.merged; + } + + async reload(): Promise { + await this.loadAll(); + this.onDidChangeEmitter.fire('catalog'); + } + + async load(): Promise { + await this.ready; + } + + sessionData(): ISessionSkillCatalogData { + const currentCatalog = (): SkillCatalog => this.merged; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get catalog() { + return currentCatalog(); + }, + }; + } + + private async loadAll(): Promise { + for (const s of this.sources) { + await this.loadSource(s); + } + this.remerge(); + } + + private async reloadSource(id: string): Promise { + const s = this.sources.find((x) => x.id === id); + if (!s) return; + await this.loadSource(s, true); + } + + private loadSource(source: ISkillSource, fireChange = false): Promise { + const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(async () => { + const contribution = await source.load(); + this.contributions.set(source.id, { c: contribution, priority: source.priority }); + if (fireChange) { + this.remerge(); + this.onDidChangeEmitter.fire(source.id); + } + }); + this.sourceLoadTails.set(source, current); + const clear = () => { + if (this.sourceLoadTails.get(source) === current) { + this.sourceLoadTails.delete(source); + } + }; + void current.then(clear, clear); + return current; + } + + private remerge(): void { + const m = new InMemorySkillCatalog(); + const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); + for (const { c } of ordered) { + for (const skill of c.skills) m.register(skill, { replace: true }); + m.addRoots(c.scannedRoots ?? []); + m.recordSkipped(c.skipped ?? []); + } + this.merged = m; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts new file mode 100644 index 000000000..d889678f1 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts @@ -0,0 +1,30 @@ +/** + * `workspaceToolPolicy` domain (L2) — os-level tool enable/disable contract. + * + * Defines the `IWorkspaceToolPolicy`, the Workspace-scope owner of the + * tool-veto set that outranks every Agent-side policy layer (profile × + * `[tools]` config × session denylist): a tool the workspace disables never + * activates and can never execute, no matter what the upper layers allow. + * The set derives from the handler's runtime capabilities — the + * `IWorkspaceContext.osBackendId` keying pair records which os backend the + * handler binds; a runtime whose backend lacks a capability (e.g. no PTY) + * contributes the dependent tool names here. The set reaches every session + * of the handler through the `ISessionToolPolicyGate` seed + * (`sessionGate()`), a live read view over this service. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; + +export interface IWorkspaceToolPolicy { + readonly _serviceBrand: undefined; + + disabledTools(): readonly string[]; + readonly onDidChange: Event; + + sessionGate(): ISessionToolPolicyGate; +} + +export const IWorkspaceToolPolicy: ServiceIdentifier = + createDecorator('workspaceToolPolicy'); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts new file mode 100644 index 000000000..d63ecaee9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts @@ -0,0 +1,75 @@ +/** + * `workspaceToolPolicy` domain (L2) — `IWorkspaceToolPolicy` implementation. + * + * Computes the os-level disabled-tool set from the runtime capabilities the + * handler binds (`IWorkspaceContext.osBackendId`). The local runtime carries + * the full node os backend (fs / process / PTY / watch), so it vetoes + * nothing; runtimes with a reduced os backend land their own capability + * mapping (or seed their own `IWorkspaceToolPolicy`) when they arrive. A + * workspace-level tools config does not exist yet — when one does, it joins + * the capability set here and fires `onDidChange`. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { + IWorkspaceContext, + LOCAL_OS_BACKEND_ID, +} from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceToolPolicy } from './workspaceToolPolicy'; + +/** + * The os-level veto set for one os backend. Pure so the capability mapping is + * unit-testable without a scope. The local backend has every capability, so + * it disables nothing. + * + * An UNKNOWN backend also disables nothing — that fail-open is a deliberate + * choice: the veto is a safety override, and guessing restrictions for a + * backend nobody has mapped would break tools on a runtime that may be fully + * capable. The cost is the opposite silence: a NEW runtime that forgets to + * extend this mapping ships with no os-level veto. When a new os backend + * lands, extend this mapping (or seed its own `IWorkspaceToolPolicy`) as + * part of the runtime bring-up. + */ +export function computeCapabilityDisabledTools(osBackendId: string): readonly string[] { + if (osBackendId === LOCAL_OS_BACKEND_ID) return []; + return []; +} + +export class WorkspaceToolPolicyService extends Disposable implements IWorkspaceToolPolicy { + declare readonly _serviceBrand: undefined; + + private readonly disabled: readonly string[]; + readonly onDidChange = Event.None as Event; + + constructor(@IWorkspaceContext workspace: IWorkspaceContext) { + super(); + this.disabled = computeCapabilityDisabledTools(workspace.osBackendId); + } + + disabledTools(): readonly string[] { + return this.disabled; + } + + sessionGate(): ISessionToolPolicyGate { + const current = (): readonly string[] => this.disabledTools(); + return { + _serviceBrand: undefined, + onDidChange: this.onDidChange, + get disabledTools() { + return current(); + }, + }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts new file mode 100644 index 000000000..13001afc2 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts @@ -0,0 +1,45 @@ +/** + * `workspaceTrust` domain (L2) — per-workspace trust-state contract. + * + * Defines `IWorkspaceTrust`, the Workspace-scope owner of one yes/no fact: + * has the user trusted this workspace. Trust gates everything the + * workspace's own files may ask the engine to run before those files are + * read as instructions; the first consumer is `workspaceMcpConfig`, which + * skips the project-level MCP config files (project-root `.mcp.json` and + * `.kimi-code/mcp.json`) while the workspace is untrusted, so a freshly + * cloned repo cannot auto-start MCP servers. The marker is recorded OUTSIDE + * the workspace (see the impl for the storage layout) so a malicious + * checkout cannot mark itself trusted, and every handler of the same root + * resolves to the same record. Trust flips only through the explicit + * `trust()` / `untrust()` calls — the engine has no interactive prompt. + * Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +export interface WorkspaceTrustChange { + readonly trusted: boolean; +} + +export interface IWorkspaceTrust { + readonly _serviceBrand: undefined; + + /** Settles once the persisted marker has been read; never rejects. */ + readonly ready: Promise; + + /** Awaits {@link ready} and returns the trust state — the RPC-friendly read. */ + get(): Promise; + + /** Meaningful after {@link ready}; untrusted until proven otherwise. */ + isTrusted(): boolean; + + /** Idempotent; fires {@link onDidChange} only on a real flip. */ + trust(): Promise; + untrust(): Promise; + + readonly onDidChange: Event; +} + +export const IWorkspaceTrust: ServiceIdentifier = + createDecorator('workspaceTrust'); diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts new file mode 100644 index 000000000..bbcc3282b --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -0,0 +1,95 @@ +/** + * `workspaceTrust` domain (L2) — `IWorkspaceTrust` implementation. + * + * Persists the trust marker through the `persistence` domain's + * `IAtomicDocumentStore` under the `workspace-trust` scope, one document per + * workspace keyed by `encodeWorkDirKey(root)` — the same id the workspace + * registry mints for the root — with the raw root kept in the value for + * inspection. The document's presence IS the trusted state: `trust()` + * writes it, `untrust()` deletes it. The record lives under the kimi home, + * never inside the workspace, so a checked-out tree cannot pre-trust + * itself. The flag is read once through `ready` and every later mutation + * goes through this service, so the view is in-process: another process + * flipping the same record is picked up only on restart (a `docs.watch` + * sync can join when a second writer exists). A read failure resolves to + * untrusted. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter } from '#/_base/event'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust'; + +const TRUST_SCOPE = 'workspace-trust'; + +interface TrustRecord { + readonly root: string; + readonly trustedAt: number; +} + +export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + private readonly root: string; + private readonly storeKey: string; + private trusted = false; + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange = this.changeEmitter.event; + + constructor( + @IWorkspaceContext workspace: IWorkspaceContext, + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + ) { + super(); + this.root = workspace.cwd; + this.storeKey = encodeWorkDirKey(workspace.cwd); + this.ready = this.initialize(); + } + + isTrusted(): boolean { + return this.trusted; + } + + async get(): Promise { + await this.ready; + return this.trusted; + } + + async trust(): Promise { + if (this.trusted) return; + await this.docs.set(TRUST_SCOPE, this.storeKey, { + root: this.root, + trustedAt: Date.now(), + }); + this.trusted = true; + this.changeEmitter.fire({ trusted: true }); + } + + async untrust(): Promise { + if (!this.trusted) return; + await this.docs.delete(TRUST_SCOPE, this.storeKey); + this.trusted = false; + this.changeEmitter.fire({ trusted: false }); + } + + private async initialize(): Promise { + try { + this.trusted = (await this.docs.get(TRUST_SCOPE, this.storeKey)) !== undefined; + } catch { + this.trusted = false; + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceTrust, + WorkspaceTrustService, + ScopeActivation.OnDemand, + 'workspaceTrust', +); diff --git a/packages/agent-core-v2/test/_base/contribution/registry.test.ts b/packages/agent-core-v2/test/_base/contribution/registry.test.ts new file mode 100644 index 000000000..bfaef5501 --- /dev/null +++ b/packages/agent-core-v2/test/_base/contribution/registry.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { ContributionRegistry } from '#/_base/contribution/registry'; + +interface TestContribution { + readonly items: readonly string[]; +} + +describe('ContributionRegistry', () => { + it('stores one entry per sourceId and replaces on re-register', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }, { priority: 10 }); + registry.register('a', { items: ['2'] }, { priority: 20 }); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ sourceId: 'a', priority: 20, contribution: { items: ['2'] } }); + registry.dispose(); + }); + + it('defaults priority to 0 and exposes entries with metadata', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }); + registry.register('b', { items: ['2'] }, { priority: 5 }); + + expect(registry.get('a')?.priority).toBe(0); + expect(registry.get('b')?.priority).toBe(5); + expect(registry.entries().map((e) => e.sourceId)).toEqual(['a', 'b']); + registry.dispose(); + }); + + it('unregister removes the entry and is idempotent', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }); + registry.unregister('a'); + registry.unregister('a'); + + expect(registry.entries()).toHaveLength(0); + expect(registry.get('a')).toBeUndefined(); + registry.dispose(); + }); + + it('handle dispose unregisters only the entry it registered', () => { + const registry = new ContributionRegistry(); + const stale = registry.register('a', { items: ['old'] }); + registry.register('a', { items: ['new'] }); + + stale.dispose(); + + expect(registry.get('a')?.contribution.items).toEqual(['new']); + registry.dispose(); + }); + + it('handle dispose is idempotent', () => { + const registry = new ContributionRegistry(); + const handle = registry.register('a', { items: ['1'] }); + handle.dispose(); + handle.dispose(); + + expect(registry.entries()).toHaveLength(0); + registry.dispose(); + }); + + it('fires onDidChange with the sourceId on register, re-register, and unregister', () => { + const registry = new ContributionRegistry(); + const seen: string[] = []; + registry.onDidChange((sourceId) => seen.push(sourceId)); + + registry.register('a', { items: ['1'] }); + registry.register('a', { items: ['2'] }); + registry.unregister('a'); + registry.unregister('missing'); + + expect(seen).toEqual(['a', 'a', 'a']); + registry.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/text/frontmatter.test.ts b/packages/agent-core-v2/test/_base/text/frontmatter.test.ts new file mode 100644 index 000000000..5e813093e --- /dev/null +++ b/packages/agent-core-v2/test/_base/text/frontmatter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; + +describe('parseFrontmatter', () => { + it('parses yaml frontmatter and body', () => { + const { data, body } = parseFrontmatter('---\nname: foo\n---\nbody text'); + expect(data).toEqual({ name: 'foo' }); + expect(body).toBe('body text'); + }); + + it('returns null data when there is no frontmatter', () => { + const { data, body } = parseFrontmatter('just body'); + expect(data).toBeNull(); + expect(body).toBe('just body'); + }); + + it('throws when the closing fence is missing', () => { + expect(() => parseFrontmatter('---\nname: foo')).toThrow(FrontmatterError); + }); +}); diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index d1962f12f..dd9ca41a7 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -11,13 +11,13 @@ import { Event } from '#/_base/event'; import { abortError } from '#/_base/utils/abort'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { McpConnectionManager, McpServerEntry } from '#/agent/mcp/connection-manager'; +import type { McpConnectionManager, McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { AgentMcpService } from '#/agent/mcp/mcpService'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; -import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import { IWireService } from '#/wire/wire'; import type { WireRecord } from '#/wire/record'; import { McpDiscoveryModel } from '#/agent/mcp/mcpDiscoveryOps'; @@ -37,7 +37,7 @@ import { stubLoopWithHooks } from '../loop/stubs'; import { stubToolResultTruncationService } from '../toolResultTruncation/stubs'; import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs'; -import { discoverTools, executeTool, fakeMcpClient } from './stubs'; +import { discoverTools, executeTool, fakeMcpClient } from '../../mcpCore/stubs'; const MCP_OUTPUT_TRUNCATED_TEXT = '\n\n[Output truncated: exceeded 100000 character limit. ' + @@ -220,10 +220,11 @@ describe('AgentMcpService', () => { }); function createService(manager: FakeMcpManager): AgentMcpService { - ix.stub(ISessionMcpService, { - ensureMcpReady: () => Promise.resolve(), - connectionManager: () => manager as unknown as McpConnectionManager, - }); + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: manager as unknown as McpConnectionManager, + } satisfies ISessionMcpHandle); ix.stub(ISessionContext, { sessionDir: '/tmp/kimi-code-mcp-test' }); const svc = ix.createInstance(AgentMcpService); disposables.add(svc); diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index e051fd31d..c2f0443da 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from 'vitest'; import type { ITelemetryService, TelemetryProperties } from '#/app/telemetry/telemetry'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '#/agent/mcp/output'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; -import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/agent/mcp/types'; +import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; import type { ToolExecution } from '#/tool/toolContract'; import { sniffImageDimensions } from '#/agent/media/file-type'; diff --git a/packages/agent-core-v2/test/agent/mcp/session-config.test.ts b/packages/agent-core-v2/test/agent/mcp/session-config.test.ts deleted file mode 100644 index 9ad619d41..000000000 --- a/packages/agent-core-v2/test/agent/mcp/session-config.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { mergeCallerMcpServers, type SessionMcpConfig } from '#/agent/mcp/session-config'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; - -const stdio = (command: string): McpServerConfig => ({ - transport: 'stdio', - command, -}); - -const http = (url: string): McpServerConfig => ({ - transport: 'http', - url, -}); - -describe('mergeCallerMcpServers', () => { - it('returns base unchanged when callerServers is undefined', () => { - const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; - expect(mergeCallerMcpServers(base, undefined)).toBe(base); - }); - - it('returns base unchanged when callerServers is empty', () => { - const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; - expect(mergeCallerMcpServers(base, {})).toBe(base); - }); - - it('returns undefined when both base and callerServers are absent', () => { - expect(mergeCallerMcpServers(undefined, undefined)).toBeUndefined(); - expect(mergeCallerMcpServers(undefined, {})).toBeUndefined(); - }); - - it('promotes a caller-only payload into a fresh SessionMcpConfig when base is undefined', () => { - const callerServers = { docs: http('https://mcp.example.com') }; - expect(mergeCallerMcpServers(undefined, callerServers)).toEqual({ - servers: { docs: http('https://mcp.example.com') }, - }); - }); - - it('layers caller on top of base with caller winning on key collision', () => { - const base: SessionMcpConfig = { - servers: { - shared: stdio('disk-version'), - diskOnly: stdio('disk-only'), - }, - }; - const callerServers = { - shared: stdio('caller-version'), - callerOnly: http('https://caller.example.com'), - }; - expect(mergeCallerMcpServers(base, callerServers)).toEqual({ - servers: { - shared: stdio('caller-version'), - diskOnly: stdio('disk-only'), - callerOnly: http('https://caller.example.com'), - }, - }); - }); -}); diff --git a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts index bfa958df7..7b07717e9 100644 --- a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts @@ -1,11 +1,11 @@ import { MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE } from '#/agent/mcp/tools/auth'; import { describe, expect, it } from 'vitest'; -import { AlreadyAuthorizedError, type BeginAuthorizationResult, type McpOAuthService } from '#/agent/mcp/oauth/service'; +import { AlreadyAuthorizedError, type BeginAuthorizationResult, type McpOAuthService } from '#/mcpCore/oauth/service'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; import type { ToolUpdate } from '#/tool/toolContract'; -import { executeTool } from '../stubs'; +import { executeTool } from '../../../mcpCore/stubs'; function fakeOAuthService( begin: ( diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 47e133f41..a95a124f3 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -25,7 +25,10 @@ import { type PermissionRule, } from '#/agent/permissionRules/permissionRules'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IGitService } from '#/app/git/git'; +import { findGitWorkTree } from '#/app/git/workTree'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -34,6 +37,8 @@ import { recordingTelemetry } from '../../app/telemetry/stubs'; const signal = new AbortController().signal; +const hostFs = new HostFileSystem(); + describe('AgentPermissionPolicyService chain', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -59,9 +64,10 @@ describe('AgentPermissionPolicyService chain', () => { rules: () => rules, sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, })); - reg.defineInstance(ISessionWorkspaceContext, workspace); + reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IGitService, { findWorkTree: async () => null }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -196,9 +202,12 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { makeAgentScopeContext({ agentId: 'main', agentScope: '' }), ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub()); - reg.defineInstance(ISessionWorkspaceContext, workspace); + reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IGitService, { + findWorkTree: (cwd: string) => findGitWorkTree(hostFs, cwd), + }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -475,32 +484,26 @@ function stringArg( return typeof value === 'string' ? value : fallback; } -function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext { - let workDir = initialWorkDir; +function workspaceStub(initialWorkDir: string): { + readonly stub: ISessionWorkspaceContext; + addAdditionalDir(dir: string): void; +} { let additionalDirs: string[] = []; - return { + const stub: ISessionWorkspaceContext = { _serviceBrand: undefined, - get workDir() { - return workDir; - }, + workDir: initialWorkDir, get additionalDirs() { return additionalDirs; }, - setWorkDir: (nextWorkDir) => { - workDir = nextWorkDir; - }, - setAdditionalDirs: (dirs) => { - additionalDirs = [...dirs]; - }, resolve: (path) => path, isWithin: () => true, assertAllowed: (path) => path, + }; + return { + stub, addAdditionalDir: (dir) => { if (!additionalDirs.includes(dir)) additionalDirs = [...additionalDirs, dir]; }, - removeAdditionalDir: (dir) => { - additionalDirs = additionalDirs.filter((candidate) => candidate !== dir); - }, }; } diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index 8977ccdc6..56f2671a5 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'; // deny rules adjudicate. import '#/agent/plan/planService'; import type { ToolCall } from '#/kosong/contract/message'; -import { dirname, isAbsolute, join } from 'pathe'; +import { dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; @@ -181,9 +181,7 @@ describe('Plan service', () => { it('enters plan mode without starting a model turn and prepares the plan directory', async () => { const mkdir = vi.fn().mockResolvedValue(undefined); const writeText = vi.fn().mockResolvedValue(0); - const cwd = await makeTempDir('kimi-plan-entry-'); useFakes(createPlanFakes({ mkdir, writeText })); - profile.update({ cwd }); await ctx.rpc.enterPlan({}); await delay(10); @@ -198,11 +196,9 @@ describe('Plan service', () => { }); it('derives the plan path from the agent homedir on enter and restore', async () => { - const cwd = await makeTempDir('kimi-plan-path-'); useFakes(createPlanFakes({ writeText: vi.fn(async (_path: string, _content: string): Promise => {}), })); - profile.update({ cwd }); await plan.enter('stable-plan'); const livePath = await expectActivePlanPath(); @@ -225,25 +221,10 @@ describe('Plan service', () => { expect(await expectActivePlanPath()).toBe(livePath); }); - it('keeps the plan path under the agent homedir when the profile cwd is empty', async () => { - useFakes(createPlanFakes({ - writeText: vi.fn(async (_path: string, _content: string): Promise => {}), - })); - profile.update({ cwd: '' }); - - await plan.enter('homedir-plan'); - - const planPath = await expectActivePlanPath(); - expect(isAbsolute(planPath)).toBe(true); - expect(planPath).toBe(expectedPlanPath('homedir-plan')); - }); - it('enters plan mode through the EnterPlanMode tool and reminds the next step', async () => { - const cwd = await makeTempDir('kimi-plan-tool-entry-'); const { fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['EnterPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); const enterPlanModeCall: ToolCall = { @@ -266,10 +247,8 @@ describe('Plan service', () => { describe('plan clear', () => { it('empties the current plan file without leaving plan mode', async () => { - const cwd = await makeTempDir('kimi-plan-clear-'); const { files, writeText, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('test-plan', false); const planPath = await expectActivePlanPath(); @@ -313,10 +292,8 @@ describe('Plan service', () => { }); it('snapshots the current plan file into a versioned blob with a reference record', async () => { - const cwd = await makeTempDir('kimi-plan-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -339,10 +316,8 @@ describe('Plan service', () => { }); it('increments the version on every recording and keeps earlier blobs', async () => { - const cwd = await makeTempDir('kimi-plan-revision-twice-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -357,10 +332,8 @@ describe('Plan service', () => { }); it('mints the next version from the replayed counter', async () => { - const cwd = await makeTempDir('kimi-plan-revision-restore-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -385,11 +358,9 @@ describe('Plan service', () => { }); it('records a revision when ExitPlanMode submits the plan', async () => { - const cwd = await makeTempDir('kimi-plan-submit-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'auto' }); await plan.enter('submit-plan', false); @@ -414,11 +385,9 @@ describe('Plan service', () => { }); it('records the next version when a revised plan is resubmitted', async () => { - const cwd = await makeTempDir('kimi-plan-revise-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('revise-plan', false); @@ -458,10 +427,8 @@ describe('Plan service', () => { }); it('does not record a revision on clear or on plan file writes', async () => { - const cwd = await makeTempDir('kimi-plan-no-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('quiet-plan', false); const planPath = await expectActivePlanPath(); @@ -476,11 +443,9 @@ describe('Plan service', () => { describe('plan exit tool', () => { it('reads the current plan file and exits plan mode directly in auto mode', async () => { - const cwd = await makeTempDir('kimi-plan-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'auto' }); await plan.enter('test-plan', false); @@ -508,11 +473,9 @@ describe('Plan service', () => { }); it('stops the turn and stays in plan mode when the user rejects the plan', async () => { - const cwd = await makeTempDir('kimi-plan-reject-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('reject-plan', false); @@ -542,7 +505,6 @@ describe('Plan service', () => { const exec = vi.fn(() => { throw new Error('Bash should not execute after plan rejection'); }); - const cwd = await makeTempDir('kimi-plan-reject-skip-tool-'); const { files, fakes: baseFakes } = createPlanFileFakes(undefined); const fakes: PlanFakes = { fs: baseFakes.fs, @@ -550,7 +512,6 @@ describe('Plan service', () => { }; useFakes(fakes); useTools(['ExitPlanMode', 'Bash']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); await plan.enter('reject-and-exit-plan', false); @@ -590,11 +551,9 @@ describe('Plan service', () => { }); it('refuses to exit when the current plan file is empty', async () => { - const cwd = await makeTempDir('kimi-plan-empty-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); await plan.enter('empty-plan', false); @@ -622,11 +581,9 @@ describe('Plan service', () => { describe('plan exit tool options', () => { it('keeps options for approval when an option omits the optional description', async () => { - const cwd = await makeTempDir('kimi-plan-options-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('options-plan', false); @@ -673,9 +630,7 @@ describe('Plan service', () => { files.set(path, content); }); useFakes(createPlanFakes({ readText, writeText })); - const cwd = await makeTempDir('kimi-plan-write-tool-'); useTools([toolName]); - profile.update({ cwd }); await plan.enter('test-plan', false); const planPath = await expectActivePlanPath(); @@ -714,9 +669,7 @@ describe('Plan service', () => { files.set(path, content); }); useFakes(createPlanFakes({ writeText })); - const cwd = await makeTempDir('kimi-plan-deny-write-'); useTools(['Write']); - profile.update({ cwd }); permissionRules.addRules([ { decision: 'deny', diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index ba2570c73..0a67d1b4e 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -11,7 +11,7 @@ import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSystemPrompt } from '#/app/plugin/types'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import { appService, @@ -104,7 +104,6 @@ describe('AgentProfileService.applyProfile', () => { it('refreshes the active profile system prompt exactly without resetting active tools', async () => { await writeFile(join(workDir, 'AGENTS.md'), 'old instructions', 'utf-8'); const { profile: svc } = buildContext(); - svc.update({ cwd: workDir }); await svc.applyProfile(exactProfile); svc.update({ activeToolNames: ['Read'] }); await writeFile(join(workDir, 'AGENTS.md'), 'new instructions', 'utf-8'); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 8e2f4545c..3b78e4204 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -7,7 +7,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { Event } from '#/_base/event'; import { ConfigTarget, IConfigService } from '#/app/config/config'; import { TOOLS_SECTION } from '#/agent/toolPolicy/configSection'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import type { ToolCall } from '#/kosong/contract/message'; import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile'; @@ -19,6 +21,7 @@ import { IAtomicDocumentStore, type IAtomicDocumentStore as AtomicDocumentStore import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWireService } from '#/wire/wire'; import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/tool/toolContract'; @@ -91,10 +94,11 @@ describe('AgentProfileService.bind', () => { } it('binds a profile + model atomically and becomes runnable', async () => { - const { ctx: context, profile: svc } = buildContext(); + const { profile: svc } = buildContext(); - const catalog = context.get(IAgentProfileCatalogService); + const catalog = new BuiltinAgentProfileLoaderService(new AgentProfileRegistryService()); expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); + catalog.dispose(); expect(svc.isRunnable()).toBe(false); @@ -136,7 +140,6 @@ describe('AgentProfileService.bind', () => { profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, thinking: 'low', - cwd: homeDir, }); await ctx.get(IWireService).flush(); @@ -144,7 +147,6 @@ describe('AgentProfileService.bind', () => { expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ type: 'profile.bind', - cwd: homeDir, profileName: DEFAULT_AGENT_PROFILE_NAME, modelAlias: MOCK_MODEL, thinkingEffort: 'on', @@ -197,6 +199,23 @@ describe('AgentProfileService.bind', () => { }); }); + it('refreshes the system prompt from the session cwd after a default bind', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'kimi-bind-work-')); + try { + await writeFile(join(workDir, 'AGENTS.md'), 'v1 instructions', 'utf-8'); + ctx = createTestAgent(hostEnvironmentServices(homeDir), { cwd: workDir }); + const svc = ctx.get(IAgentProfileService); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + await writeFile(join(workDir, 'AGENTS.md'), 'v2 instructions', 'utf-8'); + await svc.refreshSystemPrompt(); + + expect(svc.getSystemPrompt()).toContain('v2 instructions'); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + it('setModel applies the default profile when none is bound yet', async () => { const { profile: svc } = buildContext(); @@ -806,6 +825,57 @@ describe('AgentToolPolicyService executor enforcement', () => { expect(probe.calls).toBe(0); }); + // Phase-4 behavior contract: the workspace (os-level) veto — seeded as + // `ISessionToolPolicyGate` — blocks direct execution just like the classic + // layers, and it wins over every one of them. + it('blocks a direct builtin call through the workspace tool-policy gate', async () => { + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: ['PolicyProbe'], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate), + ); + await ctx.get(IAgentProfileService).bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: MOCK_MODEL, + }); + const probe = new PolicyProbeTool('PolicyProbe'); + ctx.get(IAgentToolRegistryService).register(probe); + + const result = await executeDirectToolCall(ctx, 'PolicyProbe'); + + expect(result).toMatchObject({ + isError: true, + output: 'Tool "PolicyProbe" is disabled by the active tool policy', + }); + expect(probe.calls).toBe(0); + }); + + // The prompt projection goes through the same workspace veto: a profile + // whose prompt renders `skillActive` must see the Skill tool as inactive + // when the gate disables it (profileService's `isToolActiveForProfile`). + it('applies the workspace gate in the prompt projection (skillActive)', async () => { + registerAgentProfile({ + name: 'gate-skill-active', + tools: ['Read', 'Skill'], + systemPrompt: (context) => `skill-active:${String(context.skillActive)}`, + }); + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: ['Skill'], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate), + ); + const profileService = ctx.get(IAgentProfileService); + await profileService.bind({ profile: 'gate-skill-active', model: MOCK_MODEL }); + + expect(profileService.data().systemPrompt).toBe('skill-active:false'); + }); + it('does not reject select_tools, the policy-gated disclosure loading entry', async () => { ctx = createTestAgent(hostEnvironmentServices(homeDir)); // The default profile's allowlist does not name select_tools; the guard diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index b00106a87..5becafb84 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; import { ActiveToolsModel, ProfileModel } from '#/agent/profile/profileOps'; @@ -21,13 +22,13 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; -import { IPluginService } from '#/app/plugin/plugin'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IWireService } from '#/wire/wire'; @@ -213,10 +214,6 @@ function buildHost(key: string): { host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); host.stub(IHostIdentity, stubUnused()); - host.stub(IPluginService, { - _serviceBrand: undefined, - onDidReload: () => ({ dispose: () => {} }), - }); host.stub(IBootstrapService, stubUnused()); host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); @@ -225,6 +222,13 @@ function buildHost(key: string): { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), }); + host.stub(ISessionInstructionsProvider, { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None as Event, + } satisfies ISessionInstructionsProvider); host.stub(ISessionToolPolicy, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -308,14 +312,12 @@ describe('AgentProfileService (wire-backed config.update)', () => { it('persists and replays an allowlist reset to unrestricted', async () => { svc.applyBindingSnapshot({ - cwd: '/work', profileName: 'restricted', thinkingLevel: 'off', systemPrompt: 'restricted', activeToolNames: ['Read'], }); svc.applyBindingSnapshot({ - cwd: '/work', profileName: 'unrestricted', thinkingLevel: 'off', systemPrompt: 'unrestricted', @@ -334,31 +336,22 @@ describe('AgentProfileService (wire-backed config.update)', () => { replay.ix.dispose(); }); - it('chdir and emitStatusUpdated run live-only and are silent during replay', async () => { - let chdirCalls = 0; + it('emitStatusUpdated runs live-only and is silent during replay', async () => { let statusEmits = 0; svc.configure({ - chdir: () => { - chdirCalls += 1; - }, emitStatusUpdated: () => { statusEmits += 1; }, }); - svc.update({ cwd: '/work', profileName: DEFAULT_AGENT_PROFILE_NAME }); - expect(chdirCalls).toBe(1); + svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME }); expect(statusEmits).toBe(1); const records = await readRecords(); const host = buildHost('profile-replay'); - let replayChdir = 0; let replayEmits = 0; host.svc.configure({ - chdir: () => { - replayChdir += 1; - }, emitStatusUpdated: () => { replayEmits += 1; }, @@ -370,9 +363,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { testWireScope(SCOPE, 'profile-replay'), records, ); - expect(modelOf(host.wire).cwd).toBe('/work'); expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); - expect(replayChdir).toBe(0); expect(replayEmits).toBe(0); const written: WireRecord[] = []; diff --git a/packages/agent-core-v2/test/agent/task/taskManager.test.ts b/packages/agent-core-v2/test/agent/task/taskManager.test.ts index c51f2358d..09d77da80 100644 --- a/packages/agent-core-v2/test/agent/task/taskManager.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskManager.test.ts @@ -21,6 +21,7 @@ import { } from '#/agent/tools/agent/subagent-task'; import { ProcessTask } from '#/agent/tools/os/bash/process-task'; import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { configServices, createTestAgent, @@ -1276,11 +1277,16 @@ describe('AgentTaskService', () => { it('getTask on an unknown id does not create persisted state', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-mgr-missing-')); try { - const { manager, persistence } = createAgentTaskService({ sessionDir }); + const { ctx, manager, persistence } = createAgentTaskService({ sessionDir }); expect(manager.getTask('bash-bogusss0')).toBeUndefined(); expect(await persistence!.listTasks()).toEqual([]); + // The session scope's initial metadata write is kicked at creation but + // not awaited by the (synchronous) harness; settle it before the + // cleanup below removes the home dir, the same way session + // materialization awaits metadata readiness in production. + await ctx.get(ISessionMetadata).ready; } finally { await rm(sessionDir, { recursive: true, force: true }); } diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 2ed9a3898..68ec8ae17 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -9,6 +9,7 @@ import { } from '#/_base/di/scope'; import { createServices } from '#/_base/di/test'; import { IEventBus } from '#/app/event/eventBus'; +import { Event } from '#/_base/event'; import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; import { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation'; import { AgentToolActivationService } from '#/agent/toolActivation/toolActivationService'; @@ -20,6 +21,7 @@ import { } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import type { AgentTool, ToolExecution } from '#/tool/toolContract'; class StubTool implements AgentTool { @@ -68,6 +70,7 @@ describe('AgentToolActivationService', () => { activeToolNames?: readonly string[]; disallowedTools?: readonly string[]; } = {}; + const gateData: { disabledTools: readonly string[] } = { disabledTools: [] }; function createActivationHost() { disposables = new DisposableStore(); @@ -80,6 +83,13 @@ describe('AgentToolActivationService', () => { reg.definePartialInstance(IEventBus, { subscribe: () => toDisposable(() => {}), }); + reg.defineInstance(ISessionToolPolicyGate, { + _serviceBrand: undefined, + get disabledTools() { + return gateData.disabledTools; + }, + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolActivationService, AgentToolActivationService); reg.define(IAlphaTool, AlphaTool); @@ -98,6 +108,7 @@ describe('AgentToolActivationService', () => { _clearAgentToolContributionsForTests(); delete profileData.activeToolNames; delete profileData.disallowedTools; + gateData.disabledTools = []; }); afterEach(() => { @@ -173,6 +184,38 @@ describe('AgentToolActivationService', () => { expect(gammaConstructions).toBe(0); }); + // Phase-4 behavior contract: the workspace (os-level) veto outranks the + // profile — a workspace-disabled tool never activates, so it never lands + // in `registry.list()` (and therefore never reaches the model's schema). + it('honors the workspace tool-policy veto before the profile', async () => { + gateData.disabledTools = ['Beta']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve('Beta')).toBeUndefined(); + expect(registry.list().map((t) => t.name)).not.toContain('Beta'); + expect(betaConstructions).toBe(0); + }); + + it('lets the workspace veto win over a profile allowlist', async () => { + profileData.activeToolNames = ['Alpha', 'Beta']; + gateData.disabledTools = ['Beta']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve('Beta')).toBeUndefined(); + }); + it('is idempotent and picks up newly allowed tools on re-activation', async () => { profileData.activeToolNames = ['Alpha']; registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); diff --git a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts index a21952d8b..723393adc 100644 --- a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts +++ b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { findInactiveToolPatterns, isToolActive, + isToolActiveComposed, literalToolNames, } from '#/agent/toolPolicy/evaluate'; @@ -61,3 +62,51 @@ describe('literalToolNames', () => { ).toEqual(['Read']); }); }); + +describe('isToolActiveComposed workspace veto', () => { + it('lets the workspace layer veto a tool every other layer allows', () => { + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: { tools: ['Bash', 'Read'] }, + global: { enabled: ['Bash', 'Read'] }, + sessionDisabledTools: [], + }, + 'Bash', + ), + ).toBe(false); + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: { tools: ['Bash', 'Read'] }, + }, + 'Read', + ), + ).toBe(true); + }); + + it('applies the workspace veto to MCP tools by glob', () => { + expect( + isToolActiveComposed( + { workspaceDisabledTools: ['mcp__blocked__*'], profile: {} }, + 'mcp__blocked__write', + 'mcp', + ), + ).toBe(false); + }); + + it('stays inactive when any classic layer also denies', () => { + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: {}, + sessionDisabledTools: ['Bash'], + }, + 'Bash', + ), + ).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts new file mode 100644 index 000000000..0a73ec574 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts @@ -0,0 +1,132 @@ +/** + * Scenario: the App-scope agent-profile registry service. + * + * Exercises `AgentProfileRegistryService` directly: the (sourceId, + * workspaceKey) storage-key encoding (one global entry per source id, with + * same-id workspace-local entries coexisting across handlers), the scoped + * `unregister` / dispose-handle semantics, the `entries()` metadata, and the + * decoded `onDidChange` payload. The generic contribution registry + * underneath is covered by `test/_base/contribution/registry.test.ts`; this + * suite only pins the service layer's workspaceKey dimension. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentProfileCatalog/agentProfileRegistry.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; + +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; + +function contribution(marker: string): AgentProfileContribution { + return { profiles: [{ name: marker, systemPrompt: () => marker }] }; +} + +describe('AgentProfileRegistryService', () => { + it('replaces a same-sourceId global registration', () => { + const registry = new AgentProfileRegistryService(); + registry.register('user', contribution('v1')); + registry.register('user', contribution('v2')); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.contribution.profiles[0]?.name).toBe('v2'); + registry.dispose(); + }); + + it('keeps same-sourceId entries with different workspaceKeys coexisting', () => { + const registry = new AgentProfileRegistryService(); + registry.register('workspace', contribution('global')); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); + // Re-registering one key replaces only that key's entry. + registry.register('workspace', contribution('wd_a-v2'), { workspaceKey: 'wd_a' }); + + const entries = registry.entries(); + expect(entries).toHaveLength(3); + const byKey = new Map(entries.map((entry) => [entry.workspaceKey, entry])); + expect(byKey.get(undefined)?.contribution.profiles[0]?.name).toBe('global'); + expect(byKey.get('wd_a')?.contribution.profiles[0]?.name).toBe('wd_a-v2'); + expect(byKey.get('wd_b')?.contribution.profiles[0]?.name).toBe('wd_b'); + registry.dispose(); + }); + + it('unregister(sourceId, workspaceKey) removes only the matching entry', () => { + const registry = new AgentProfileRegistryService(); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); + registry.register('user', contribution('global')); + + registry.unregister('workspace', 'wd_a'); + expect(registry.entries().map((entry) => entry.workspaceKey)).toEqual(['wd_b', undefined]); + + registry.unregister('workspace', 'wd_b'); + expect(registry.entries().map((entry) => entry.sourceId)).toEqual(['user']); + + registry.unregister('user'); + expect(registry.entries()).toHaveLength(0); + registry.dispose(); + }); + + it('a dispose handle removes only the entry it registered', () => { + const registry = new AgentProfileRegistryService(); + const stale = registry.register('workspace', contribution('old'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('new'), { workspaceKey: 'wd_a' }); + + stale.dispose(); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.contribution.profiles[0]?.name).toBe('new'); + registry.dispose(); + }); + + it('exposes sourceId, priority, workspaceKey, and contribution through entries()', () => { + const registry = new AgentProfileRegistryService(); + const pluginContribution = contribution('plugin-p'); + const workspaceContribution = contribution('ws-p'); + registry.register('plugin', pluginContribution, { priority: 5 }); + registry.register('workspace', workspaceContribution, { + priority: 30, + workspaceKey: 'wd_a', + }); + + const entries = registry.entries(); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ + sourceId: 'plugin', + priority: 5, + workspaceKey: undefined, + contribution: pluginContribution, + }); + expect(entries[1]).toEqual({ + sourceId: 'workspace', + priority: 30, + workspaceKey: 'wd_a', + contribution: workspaceContribution, + }); + // Priority defaults to 0 when omitted. + registry.register('user', contribution('user-p')); + expect(registry.entries()[2]?.priority).toBe(0); + registry.dispose(); + }); + + it('fires onDidChange with the decoded { sourceId, workspaceKey } payload', () => { + const registry = new AgentProfileRegistryService(); + const seen: { readonly sourceId: string; readonly workspaceKey?: string }[] = []; + const subscription = registry.onDidChange((change) => seen.push(change)); + + registry.register('user', contribution('global')); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.unregister('workspace', 'wd_a'); + // Unregistering a missing entry stays silent. + registry.unregister('workspace', 'wd_b'); + + expect(seen).toStrictEqual([ + { sourceId: 'user', workspaceKey: undefined }, + { sourceId: 'workspace', workspaceKey: 'wd_a' }, + { sourceId: 'workspace', workspaceKey: 'wd_a' }, + ]); + subscription.dispose(); + registry.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index 0866164e8..65d78a2f0 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -34,7 +34,7 @@ describe('BootstrapService (scoped)', () => { const svc = host.app.accessor.get(IBootstrapService); expect(svc.homeDir).toBe('/tmp/kimi-home'); expect(svc.configPath).toBe('/tmp/kimi-home/config.toml'); - expect(svc.sessionsDir).toBe('/tmp/kimi-home/sessions'); + expect(svc.scope('sessions')).toBe('sessions'); host.dispose(); }); diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index b5a097969..147b465d3 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -19,10 +19,9 @@ export const stubClientIdentity = { } as const; export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService { - const sessionsScope = 'sessions'; const scopes: Record = { config: '', - sessions: sessionsScope, + sessions: 'sessions', blobs: 'blobs', store: 'store', logs: 'logs', @@ -30,9 +29,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv credentials: 'credentials', cron: 'cron', }; - const sessionScope = (wsId: string, sId: string): string => `${sessionsScope}/${wsId}/${sId}`; - const agentScope = (wsId: string, sId: string, aId: string): string => - `${sessionScope(wsId, sId)}/agents/${aId}`; return { _serviceBrand: undefined, platform: 'linux', @@ -50,10 +46,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv logsDir: `${homeDir}/logs`, getEnv: (name) => env[name], scope: (name) => scopes[name], - sessionScope, - agentScope, - sessionDir: (wsId, sId) => `${homeDir}/${sessionScope(wsId, sId)}`, - agentHomedir: (wsId, sId, aId) => `${homeDir}/${agentScope(wsId, sId, aId)}`, }; } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index f6842a525..92d74232a 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -84,14 +84,14 @@ import { } from '#/app/auth/configSection'; import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; -import '#/agent/mcp/configSection'; +import '#/app/mcpConfig/configSection'; import { MCP_SECTION, MCP_STARTUP_TIMEOUT_ENV, MCP_TOOL_TIMEOUT_ENV, McpSectionSchema, type McpSection, -} from '#/agent/mcp/configSection'; +} from '#/app/mcpConfig/configSection'; import { ILogService } from '#/_base/log/log'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -239,11 +239,13 @@ describe('Agent config', () => { created_at: 1, }, { - type: 'config.update', + type: 'profile.bind', cwd: '/restored-cwd', modelAlias: 'restored-model', profileName: 'restored-profile', + thinkingEffort: 'off', systemPrompt: 'Restored prompt.', + disallowedTools: [], }, { type: 'tools.set_active_tools', @@ -252,7 +254,6 @@ describe('Agent config', () => { ]); expect(profile.data()).toMatchObject({ - cwd: '/restored-cwd', modelAlias: 'restored-model', profileName: 'restored-profile', systemPrompt: 'Restored prompt.', @@ -260,7 +261,7 @@ describe('Agent config', () => { }); }); - it('config.update with cwd initializes builtin tools', async () => { + it('config.update initializes builtin tools', async () => { const tools = await ctx.rpc.getTools({}); expect(toolNames(tools)).toEqual( diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index f447eed46..bf7606268 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -46,10 +46,10 @@ import { IPluginService } from '#/app/plugin/plugin'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { - ISessionLifecycleService, - type SessionLifecycleHooks, -} from '#/app/sessionLifecycle/sessionLifecycle'; -import { createHooks } from '#/hooks'; + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { createHooks, type Hooks } from '#/hooks'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { type AgentTaskHooks, @@ -182,33 +182,11 @@ function stubSessionContext(): ISessionContext { }; } -function stubSessionLifecycle(): ISessionLifecycleService { - return { - _serviceBrand: undefined, - hooks: createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]), - onDidCreateSession: Event.None as ISessionLifecycleService['onDidCreateSession'], - onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'], - onDidArchiveSession: Event.None as ISessionLifecycleService['onDidArchiveSession'], - onDidForkSession: Event.None as ISessionLifecycleService['onDidForkSession'], - create: async () => { - throw new Error('not implemented'); - }, - get: () => undefined, - list: () => [], - resume: async () => undefined, - close: async () => {}, - archive: async () => {}, - restore: async () => undefined, - fork: async () => { - throw new Error('not implemented'); - }, - createChild: async () => { - throw new Error('not implemented'); - }, - }; +function stubSessionLifecycleHooks(): Hooks { + return createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); } describe('IExternalHooksRunnerService integration', () => { @@ -512,7 +490,7 @@ describe('IExternalHooksRunnerService integration', () => { ? 'sessions/workspace-1/session-1' : `sessions/workspace-1/session-1/${subKey}`, }); - reg.defineInstance(ISessionLifecycleService, stubSessionLifecycle()); + reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: stopAgentTask.event, @@ -825,11 +803,10 @@ describe('IExternalHooksRunnerService integration', () => { const disposables = new DisposableStore(); let ix: TestInstantiationService | undefined; try { - const lifecycle = stubSessionLifecycle(); + const lifecycleHooks = stubSessionLifecycleHooks(); const path = hookLogPath(); const command = appendHookLogCommand(path); const cwd = mkdtempSync(join(tmpdir(), 'session-external-hooks-cwd-')); - const handle = {} as ISessionScopeHandle; ix = createServices(disposables, { strict: true, @@ -847,7 +824,7 @@ describe('IExternalHooksRunnerService integration', () => { ? 'sessions/workspace-1/session-1' : `sessions/workspace-1/session-1/${subKey}`, }); - reg.defineInstance(ISessionLifecycleService, lifecycle); + reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: Event.None as Event, @@ -874,31 +851,10 @@ describe('IExternalHooksRunnerService integration', () => { ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); ix.get(ISessionExternalHooksService); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'startup', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'resume', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'fork', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'other-session', - handle, - source: 'startup', - }); - await lifecycle.hooks.onWillCloseSession.run({ - sessionId: 'session-1', - handle, - reason: 'exit', - }); + await lifecycleHooks.onDidCreateSession.run({ source: 'startup' }); + await lifecycleHooks.onDidCreateSession.run({ source: 'resume' }); + await lifecycleHooks.onDidCreateSession.run({ source: 'fork' }); + await lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }); expect(readHookLog(path)).toEqual([ { diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index e2194cd5e..f5fe1623b 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -11,7 +11,8 @@ import { IRestGateway } from '#/app/gateway/gateway'; import { RestGateway } from '#/app/gateway/gatewayService'; import { ILogService } from '#/_base/log/log'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createHooks } from '#/hooks'; import { stubLog } from '../../_base/log/stubs'; @@ -87,12 +88,34 @@ describe('RestGateway', () => { dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { + const handlerService: IWorkspaceHandlerService = { _serviceBrand: undefined, + onDidCreateSession: () => ({ dispose: () => {} }), + onDidCloseSession: () => ({ dispose: () => {} }), + onDidArchiveSession: () => ({ dispose: () => {} }), + onDidForkSession: () => ({ dispose: () => {} }), create: () => Promise.resolve(sessionHandle), - get: (id) => (id === 's1' ? sessionHandle : undefined), + get: (id: string) => (id === 's1' ? sessionHandle : undefined), list: () => [sessionHandle], + resume: () => Promise.resolve(sessionHandle), close: () => Promise.resolve(), + archive: () => Promise.resolve(), + restore: () => Promise.resolve(sessionHandle), + fork: () => Promise.resolve(sessionHandle), + createChild: () => Promise.resolve(sessionHandle), + }; + const handlerHandle = { + id: 'wd_stub', + kind: LifecycleScope.Workspace, + accessor: makeAccessor([[IWorkspaceHandlerService, handlerService]]), + dispose: () => {}, + } as const; + ix.stub(IWorkspaceLifecycleService, { + _serviceBrand: undefined, + onDidMaterializeHandler: () => ({ dispose: () => {} }), + handlerFor: () => Promise.resolve(handlerHandle), + handlers: { list: () => [handlerHandle] }, + sessions: { list: () => ['s1'] }, }); ix.stub(ILogService, stubLog()); ix.set(IRestGateway, new SyncDescriptor(RestGateway)); diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index ca7a569a0..39432cebb 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,11 +9,13 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IGitService } from '#/app/git/git'; import { GitService } from '#/app/git/gitService'; +import { findGitWorkTree } from '#/app/git/workTree'; import { ErrorCodes } from '#/errors'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostProcessService } from '#/os/interface/hostProcess'; +import { normalize } from 'pathe'; function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { @@ -135,4 +137,83 @@ describe('GitService', () => { ).rejects.toMatchObject({ code: ErrorCodes.FS_PATH_NOT_FOUND }); }); }); + + describe('findWorkTree', () => { + it('finds the repo root from a nested subdirectory', async () => { + mkdirSync(join(repo, 'a', 'b'), { recursive: true }); + + const result = await service.findWorkTree(join(repo, 'a', 'b')); + + expect(result).toEqual({ + root: normalize(repo), + dotGitPath: normalize(join(repo, '.git')), + controlDirPath: normalize(join(repo, '.git')), + }); + }); + + it('returns null when no ancestor holds a .git entry', async () => { + const plain = mkdtempSync(join(tmpdir(), 'git-service-plain-')); + try { + await expect(service.findWorkTree(plain)).resolves.toBeNull(); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); + + it('resolves an absolute gitdir pointer in a .git file', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + const control = join(repo, '.git', 'worktrees', 'wt'); + writeFileSync(join(wt, '.git'), `gitdir: ${control}\n`); + + const result = await service.findWorkTree(wt); + + expect(result?.root).toBe(normalize(wt)); + expect(result?.dotGitPath).toBe(normalize(join(wt, '.git'))); + expect(result?.controlDirPath).toBe(normalize(control)); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('resolves a relative gitdir pointer against the marker parent', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + writeFileSync(join(wt, '.git'), 'gitdir: ../gitdir-target\n'); + + const result = await service.findWorkTree(wt); + + expect(result?.controlDirPath).toBe(normalize(join(wt, '..', 'gitdir-target'))); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('parses a BOM-prefixed gitdir pointer', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + writeFileSync(join(wt, '.git'), '\uFEFFgitdir: ../target\n'); + + const result = await service.findWorkTree(wt); + + expect(result?.controlDirPath).toBe(normalize(join(wt, '..', 'target'))); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('skips a .git file without a gitdir pointer and keeps walking up', async () => { + const inner = join(repo, 'inner'); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, '.git'), 'not a pointer\n'); + + const result = await service.findWorkTree(inner); + + expect(result?.root).toBe(normalize(repo)); + }); + + it('returns null for a relative cwd', async () => { + await expect(findGitWorkTree(new HostFileSystem(), 'some/relative/path')).resolves.toBeNull(); + }); + }); }); diff --git a/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts b/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts new file mode 100644 index 000000000..646201632 --- /dev/null +++ b/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { createMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; + +describe('createMcpOAuthStore', () => { + it('round-trips JSON data through the credentials/mcp scope', async () => { + const calls: Array<{ op: string; scope: string; key: string; value?: unknown }> = []; + const docs: Pick = { + async get(scope: string, key: string): Promise { + calls.push({ op: 'get', scope, key }); + return { hello: 'world' } as T; + }, + async set(scope, key, value) { + calls.push({ op: 'set', scope, key, value }); + }, + async delete(scope, key) { + calls.push({ op: 'delete', scope, key }); + }, + }; + const store = createMcpOAuthStore(docs as unknown as IAtomicDocumentStore); + + await expect(store.read('foo.json')).resolves.toEqual({ hello: 'world' }); + await store.write('foo.json', { token: 'abc' }); + await store.remove('foo.json'); + + expect(calls).toEqual([ + { op: 'get', scope: 'credentials/mcp', key: 'foo.json' }, + { op: 'set', scope: 'credentials/mcp', key: 'foo.json', value: { token: 'abc' } }, + { op: 'delete', scope: 'credentials/mcp', key: 'foo.json' }, + ]); + }); + + it('returns undefined when the underlying document store read fails', async () => { + const store = createMcpOAuthStore({ + get: async () => { + throw new Error('corrupt json'); + }, + set: async () => {}, + delete: async () => {}, + } as unknown as IAtomicDocumentStore); + + await expect(store.read('bad.json')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index eb102235d..5d23169b6 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { toDisposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { TestInstantiationService } from '#/_base/di/test'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -10,7 +11,8 @@ import type { ContentPart } from '#/kosong/contract/message'; import { type IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionCronService } from '#/session/cron/sessionCronService'; @@ -72,10 +74,25 @@ function buildService(opts: { dispose: () => {}, } as unknown as ISessionScopeHandle; - const lifecycle = { - resume: (sessionId: string) => - Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), - } as unknown as ISessionLifecycleService; + const workspaceLifecycle = { + handlerFor: () => + Promise.resolve({ + id: opts.summary.workspaceId, + kind: LifecycleScope.Workspace, + accessor: { + get: (token: unknown): unknown => { + if (token === IWorkspaceHandlerService) { + return { + resume: (sessionId: string) => + Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), + }; + } + throw new Error('unexpected workspace service access'); + }, + }, + dispose: () => {}, + }), + } as unknown as IWorkspaceLifecycleService; const index = { get: (sessionId: string) => Promise.resolve(sessionId === opts.summary.id ? opts.summary : undefined), @@ -93,7 +110,10 @@ function buildService(opts: { acquire: () => toDisposable(() => {}), }; - return new MessageLegacyService(lifecycle, index, appendLog); + const instantiation = new TestInstantiationService(); + instantiation.stub(ISessionIndex, index); + instantiation.stub(IWorkspaceLifecycleService, workspaceLifecycle); + return new MessageLegacyService(instantiation, index, appendLog); } describe('MessageLegacyService', () => { diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 7cfdab0be..dca3deefc 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -43,13 +43,10 @@ import { } from '#/app/sessionExport/sessionExportService'; import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { - ISessionLifecycleService, - type SessionLifecycleHooks, -} from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; -import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -900,30 +897,50 @@ function registerSessionExportServices( get: async () => options.summary, countActive: async () => (options.summary === undefined || options.summary.archived ? 0 : 1), }); - reg.defineInstance(ISessionLifecycleService, { + reg.defineInstance(IWorkspaceLifecycleService, { _serviceBrand: undefined, - onDidCreateSession: noopEvent, - onDidCloseSession: noopEvent, - onDidArchiveSession: noopEvent, - onDidForkSession: noopEvent, - hooks: createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]), - create: async () => { - throw new Error('create should not be called by session export'); + onDidMaterializeHandler: noopEvent, + handlerFor: async () => { + throw new Error('handlerFor should not be called by session export'); }, - get: () => options.lifecycleHandle, - list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), - resume: async () => options.lifecycleHandle, - close: async () => {}, - archive: async () => {}, - restore: async () => options.lifecycleHandle, - fork: async () => { - throw new Error('fork should not be called by session export'); + handlers: { + list: () => [ + { + id: 'ws_live', + kind: LifecycleScope.Workspace, + accessor: accessorFrom([ + [ + IWorkspaceHandlerService, + { + _serviceBrand: undefined, + onDidCreateSession: noopEvent, + onDidCloseSession: noopEvent, + onDidArchiveSession: noopEvent, + onDidForkSession: noopEvent, + create: async () => { + throw new Error('create should not be called by session export'); + }, + get: () => options.lifecycleHandle, + list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), + resume: async () => options.lifecycleHandle, + close: async () => {}, + archive: async () => {}, + restore: async () => options.lifecycleHandle, + fork: async () => { + throw new Error('fork should not be called by session export'); + }, + createChild: async () => { + throw new Error('createChild should not be called by session export'); + }, + } satisfies IWorkspaceHandlerService, + ], + ]), + dispose: () => {}, + }, + ], }, - createChild: async () => { - throw new Error('createChild should not be called by session export'); + sessions: { + list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle.id]), }, }); reg.defineInstance(IWorkspaceService, { diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index fe4eae06a..f24257df0 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -22,7 +22,9 @@ import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -42,6 +44,43 @@ function accessor( }; } +/** Stub the index → handler → session-lifecycle chain for one live session. */ +function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHandle): void { + const handler = { + id: 'wd', + kind: LifecycleScope.Workspace, + accessor: accessor([ + [ + IWorkspaceHandlerService, + { + resume: () => Promise.resolve(session), + get: () => session, + }, + ], + ]), + dispose: () => {}, + } as const; + ix.stub(ISessionIndex, { + get: (id: string) => + Promise.resolve( + id === session.id + ? { + id: session.id, + workspaceId: 'wd', + cwd: '/workspace', + createdAt: 1, + updatedAt: 1, + archived: false, + } + : undefined, + ), + }); + ix.stub(IWorkspaceLifecycleService, { + handlerFor: () => Promise.resolve(handler), + handlers: { list: () => [handler] }, + }); +} + describe('Session legacy status (best-effort runtime state)', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -104,10 +143,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); const status = await ix.get(ISessionLegacyService).status('session-test'); @@ -182,10 +218,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); const status = await ix.get(ISessionLegacyService).status('session-capped'); @@ -231,10 +264,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); await ix.get(ISessionLegacyService).updateProfile('session-test', { diff --git a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts index f623b0269..116dcc156 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts @@ -1,31 +1,11 @@ import { describe, expect, it } from 'vitest'; import { - FrontmatterError, SkillParseError, UnsupportedSkillTypeError, - parseFrontmatter, parseSkillText, } from '#/app/skillCatalog/parser'; -describe('parseFrontmatter', () => { - it('parses yaml frontmatter and body', () => { - const { data, body } = parseFrontmatter('---\nname: foo\n---\nbody text'); - expect(data).toEqual({ name: 'foo' }); - expect(body).toBe('body text'); - }); - - it('returns null data when there is no frontmatter', () => { - const { data, body } = parseFrontmatter('just body'); - expect(data).toBeNull(); - expect(body).toBe('just body'); - }); - - it('throws when the closing fence is missing', () => { - expect(() => parseFrontmatter('---\nname: foo')).toThrow(FrontmatterError); - }); -}); - describe('parseSkillText', () => { it('parses a directory skill with required fields', () => { const skill = parseSkillText({ diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts new file mode 100644 index 000000000..49adc95bf --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { Error2, ErrorCodes } from '#/errors'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; + +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { + followWorkspaceHandlers, + getLiveSessionById, + resumeSessionById, +} from '#/app/workspaceLifecycle/sessionLookup'; + +function bootstrapStub(): IBootstrapService { + return { + homeDir: '/tmp', + scope: (name: string) => name, + } as unknown as IBootstrapService; +} + +function hostEnvironmentStub(): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: 'posix', + homeDir: '/home', + ready: Promise.resolve(), + }; +} + +/** Catalog stub that mints `encodeWorkDirKey` ids and records createOrTouch calls. */ +function catalogStub() { + const workspaces = new Map(); + const createOrTouch = vi.fn((root: string, name?: string) => { + const id = encodeWorkDirKey(root); + const existing = workspaces.get(id); + const workspace: Workspace = + existing ?? { id, root, name: name ?? 'proj', createdAt: 1, lastOpenedAt: 1 }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }); + const service: IWorkspaceService = { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; + return { service, createOrTouch }; +} + +function sessionIndexStub(): ISessionIndex { + return { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + }; +} + +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + reload: () => Promise.resolve(), + }; +} + +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { + throw new Error('not implemented'); + }, + }; +} + +function sessionStubs(): ReturnType[] { + return [ + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } satisfies ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } satisfies ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IWorkspaceSkillCatalog, (() => { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; + const onDidChange = () => ({ dispose: () => {} }); + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange, + }), + } as unknown as IWorkspaceSkillCatalog; + })()), + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IWorkspaceInstructionsService, (() => { + const onDidChange = () => ({ dispose: () => {} }); + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange, + reload: () => Promise.resolve(), + sessionProvider: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange, + }), + } as unknown as IWorkspaceInstructionsService; + })()), + stubPair(IWorkspaceMcpService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: () => { + throw new Error('not implemented'); + }, + sessionHandle: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + get connectionManager(): McpConnectionManager { + throw new Error('not implemented'); + }, + }), + } as unknown as IWorkspaceMcpService), + stubPair(IAgentLifecycleService, (() => { + const main = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as const; + return { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: (id: string) => (id === 'main' ? main : undefined), + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService; + })()), + ]; +} + +describe('WorkspaceLifecycleService', () => { + let host: ScopedTestHost | undefined; + let telemetryRecords: TelemetryRecord[]; + let createOrTouchSpy: ReturnType; + + beforeEach(() => { + telemetryRecords = []; + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnDemand, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService( + LifecycleScope.App, + IHostFileSystem, + HostFileSystem, + ScopeActivation.OnDemand, + 'hostFs', + ); + }); + + afterEach(() => { + host?.dispose(); + host = undefined; + }); + + function build(extra: ReturnType[] = []): IWorkspaceLifecycleService { + const catalog = catalogStub(); + createOrTouchSpy = catalog.createOrTouch; + host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrapStub()), + stubPair(IHostEnvironment, hostEnvironmentStub()), + stubPair(IWorkspaceService, catalog.service), + stubPair(ISessionIndex, sessionIndexStub()), + stubPair(IConfigService, { get: () => undefined } as unknown as IConfigService), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } satisfies IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => () => ({ dispose: () => {} }), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + onDidPublish: () => ({ dispose: () => {} }), + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } satisfies IEventService), + stubPair(IProjectLocalConfigService, { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [], + }), + resolveAdditionalDirs: (_base: string, dirs: readonly string[]) => + Promise.resolve([...dirs]), + appendAdditionalDir: () => Promise.reject(new Error('not implemented')), + } satisfies IProjectLocalConfigService), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), + stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + list: () => Promise.resolve([]), + save: () => Promise.resolve(), + delete: () => Promise.resolve(), + } satisfies ICronTaskPersistence), + ...sessionStubs(), + ...extra, + ]); + return host.app.accessor.get(IWorkspaceLifecycleService); + } + + it('handlerFor materializes a handler seeded with the workspace facts', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + + expect(handler.kind).toBe(LifecycleScope.Workspace); + const ctx = handler.accessor.get(IWorkspaceContext); + expect(ctx).toMatchObject({ + workspaceId: encodeWorkDirKey('/tmp/proj'), + cwd: '/tmp/proj', + source: 'local', + persistenceScope: `sessions/${encodeWorkDirKey('/tmp/proj')}`, + osBackendId: 'local', + persistenceBackendId: 'local', + }); + expect(lifecycle.handlers.list()).toEqual([handler]); + }); + + it('handlerFor joins concurrent materializations of the same workspace', async () => { + const lifecycle = build(); + + const [a, b] = await Promise.all([ + lifecycle.handlerFor({ root: '/tmp/proj' }), + lifecycle.handlerFor({ root: '/tmp/proj' }), + ]); + + expect(a).toBe(b); + expect(lifecycle.handlers.list()).toHaveLength(1); + }); + + it('two concurrent sessions of one workspace share a single handler', async () => { + const lifecycle = build(); + const workspaceId = encodeWorkDirKey('/tmp/proj'); + + const [handlerA, handlerB] = await Promise.all([ + lifecycle.handlerFor({ root: '/tmp/proj' }), + lifecycle.handlerFor({ workspaceId, root: '/tmp/proj' }), + ]); + expect(handlerA).toBe(handlerB); + + const sessions = handlerA.accessor.get(IWorkspaceHandlerService); + const [s1, s2] = await Promise.all([ + sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }), + sessions.create({ sessionId: 's2', workDir: '/tmp/proj' }), + ]); + + expect(s1.id).toBe('s1'); + expect(s2.id).toBe('s2'); + expect(lifecycle.handlers.list()).toEqual([handlerA]); + expect(lifecycle.sessions.list(workspaceId).toSorted()).toEqual(['s1', 's2']); + }); + + it('handlerFor by workspaceId returns the live handler without re-touching the catalog', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + expect(createOrTouchSpy).toHaveBeenCalledTimes(1); + + const again = await lifecycle.handlerFor({ workspaceId: encodeWorkDirKey('/tmp/proj') }); + + expect(again).toBe(handler); + // A live handler is returned as-is — no catalog write beyond the initial + // materialization. + expect(createOrTouchSpy).toHaveBeenCalledTimes(1); + }); + + it('handlerFor by unknown workspaceId without a root hint throws workspace.not_found', async () => { + const lifecycle = build(); + + await expect(lifecycle.handlerFor({ workspaceId: 'wd_missing' })).rejects.toMatchObject({ + code: ErrorCodes.WORKSPACE_NOT_FOUND, + }); + expect(lifecycle.handlers.list()).toHaveLength(0); + }); + + it('a failed handler materialization does not disturb live handlers', async () => { + const lifecycle = build(); + const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); + + const workspaces = host!.app.accessor.get(IWorkspaceService); + const original = workspaces.createOrTouch.bind(workspaces); + vi.spyOn(workspaces, 'createOrTouch').mockImplementation((root: string, name?: string) => + root === '/tmp/broken' ? Promise.reject(new Error('disk gone')) : original(root, name), + ); + + await expect(lifecycle.handlerFor({ root: '/tmp/broken' })).rejects.toThrow('disk gone'); + expect(lifecycle.handlers.list()).toEqual([first]); + expect(first.accessor.get(IWorkspaceContext).workspaceId).toBe(encodeWorkDirKey('/tmp/proj')); + }); + + it('sessions.list is empty for a non-materialized workspace', () => { + const lifecycle = build(); + expect(lifecycle.sessions.list('wd_missing')).toEqual([]); + }); + + describe('sessionLookup', () => { + function indexWith(summary: { + readonly id: string; + readonly workspaceId: string; + readonly cwd?: string; + }): ReturnType { + return stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: (id: string) => Promise.resolve(id === summary.id ? { ...summary, createdAt: 1, updatedAt: 1, archived: false } : undefined), + } as unknown as ISessionIndex); + } + + it('resumeSessionById routes index → handlerFor → handler resume', async () => { + const workspaceId = encodeWorkDirKey('/tmp/proj'); + const lifecycle = build([indexWith({ id: 's1', workspaceId, cwd: '/tmp/proj' })]); + + const handle = await resumeSessionById(host!.app.accessor, 's1'); + + expect(handle?.id).toBe('s1'); + expect(lifecycle.handlers.list()).toHaveLength(1); + expect(getLiveSessionById(host!.app.accessor, 's1')).toBe(handle); + }); + + it('resumeSessionById returns undefined for an unknown session', async () => { + build(); + await expect(resumeSessionById(host!.app.accessor, 'nope')).resolves.toBeUndefined(); + }); + + it('resumeSessionById reports session_load_failed when the index read fails', async () => { + build([ + stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: () => + Promise.reject(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'index read failed')), + } as unknown as ISessionIndex), + ]); + + await expect(resumeSessionById(host!.app.accessor, 's1')).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + expect(telemetryRecords).toContainEqual({ + event: 'session_load_failed', + properties: { sessionId: 's1', reason: ErrorCodes.SESSION_NOT_FOUND }, + }); + }); + + it('getLiveSessionById finds only live sessions', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + const sessions = handler.accessor.get(IWorkspaceHandlerService); + await sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + expect(getLiveSessionById(host!.app.accessor, 's1')?.id).toBe('s1'); + expect(getLiveSessionById(host!.app.accessor, 'other')).toBeUndefined(); + }); + + it('followWorkspaceHandlers subscribes present and future handlers', async () => { + const lifecycle = build(); + const closed: string[] = []; + const sub = followWorkspaceHandlers(host!.app.accessor, (service) => + service.onDidCloseSession((event) => closed.push(event.sessionId)), + ); + + const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); + await first.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's1', workDir: '/tmp/proj' }); + // Materialized AFTER the follow subscription — still observed. + const second = await lifecycle.handlerFor({ root: '/tmp/other' }); + await second.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's2', workDir: '/tmp/other' }); + + await first.accessor.get(IWorkspaceHandlerService).close('s1'); + await second.accessor.get(IWorkspaceHandlerService).close('s2'); + + expect(closed.toSorted()).toEqual(['s1', 's2']); + sub.dispose(); + }); + }); +}); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4a4a25174..1faa14ee2 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -24,8 +24,14 @@ import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceService'; import { IAgentGoalService } from '#/agent/goal/goal'; import { AgentGoalService } from '#/agent/goal/goalService'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import type { McpConnectionManager } from '#/agent/mcp/connection-manager'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { loadAgentsMdForRoots, type LoadedAgentsMd } from '#/agent/profile/context'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import { ISessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; @@ -142,6 +148,11 @@ import { type ScopeSeed, type ServiceIdentifier, } from '#/index'; +import { createHooks } from '#/hooks'; +import { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; @@ -680,13 +691,13 @@ export function cronServices(): TestAgentServiceOverride { export function mcpServices(options: { readonly manager?: McpConnectionManager; }): TestAgentServiceOverride { - // `AgentMcpService` now resolves the session's shared manager through - // `ISessionMcpService`; tests inject a fake manager by stubbing that service. - return sessionService(ISessionMcpService, { + // `AgentMcpService` resolves the workspace's shared manager through the + // seeded `ISessionMcpHandle`; tests inject a fake manager by stubbing it. + return sessionService(ISessionMcpHandle, { _serviceBrand: undefined, - ensureMcpReady: () => Promise.resolve(), - connectionManager: () => options.manager!, - } satisfies ISessionMcpService); + ready: Promise.resolve(), + connectionManager: options.manager!, + } satisfies ISessionMcpHandle); } export function skillServices( @@ -1144,7 +1155,7 @@ export class AgentTestContext { const agentTelemetry = this.root.accessor .get(ITelemetryService) .withContext({ agent_id: agentId }); - const sessionScope = bootstrap.sessionScope(workspaceId, sessionId); + const sessionScope = `${bootstrap.scope('sessions')}/${workspaceId}/${sessionId}`; this.session = this.root.createChild(LifecycleScope.Session, sessionId, { extra: collectScopeSeed( [ @@ -1153,15 +1164,51 @@ export class AgentTestContext { _serviceBrand: undefined, sessionId, workspaceId, - sessionDir: bootstrap.sessionDir(workspaceId, sessionId), + sessionDir: `${bootstrap.homeDir}/${sessionScope}`, metaScope: `${sessionScope}/session-meta`, cwd: this.cwd, scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }); + reg.defineInstance( + ISessionLifecycleHooks, + createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]), + ); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); + // Workspace-resource injection contracts (the seeds the real + // handler hands each session): the harness has no Workspace + // scope, so it seeds equivalents directly — an empty skill + // catalog, the workspace key the Session agent-profile catalog + // reads the App registry with, a live-read AGENTS.md + // provider, and a no-server MCP manager. Tests replace them + // through the usual service overrides. + reg.defineInstance(ISessionSkillCatalogData, { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog: new InMemorySkillCatalog(), + onDidChange: Event.None as Event, + } satisfies ISessionSkillCatalogData); + reg.defineInstance(ISessionAgentProfileCatalogSeed, { + _serviceBrand: undefined, + workspaceKey: workspaceId, + } satisfies ISessionAgentProfileCatalogSeed); + reg.defineInstance(ISessionInstructionsProvider, this.createInstructionsProvider()); + reg.defineInstance(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: new McpConnectionManager(), + } satisfies ISessionMcpHandle); + reg.defineInstance(ISessionWorkspaceInfo, { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: [], + onDidChange: Event.None as Event, + } satisfies ISessionWorkspaceInfo); reg.defineInstance(IAgentLifecycleService, { _serviceBrand: undefined, onDidCreate: Event.None as Event, @@ -1234,7 +1281,7 @@ export class AgentTestContext { reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService)); reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService)); reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService)); - const agentScope = bootstrap.agentScope(workspaceId, sessionId, agentId); + const agentScope = `${sessionScope}/agents/${agentId}`; reg.defineInstance(IAgentScopeContext, { _serviceBrand: undefined, agentId, @@ -1249,14 +1296,6 @@ export class AgentTestContext { ), }); - this.get(IAgentProfileService).configure({ - cwd: () => this.cwd, - chdir: async (nextCwd: string) => { - this.cwd = nextCwd; - workspace.setWorkDir(nextCwd); - }, - }); - this.initializeRestorableServices(); // Resolve the activity view so its constructor subscriptions publish // `agent.activity.updated` — production ignites it in agentLifecycle. @@ -1372,7 +1411,6 @@ export class AgentTestContext { this.configureRuntimeModel(provider, modelCapabilities); const profile = this.get(IAgentProfileService); profile.update({ - cwd: process.cwd(), modelAlias: provider.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, thinkingLevel: 'off', @@ -1716,12 +1754,11 @@ export class AgentTestContext { async expectResumeMatches(): Promise { await this.waitForSessionMetadata(); await this.drainWirePersistence(); - const profile = this.get(IAgentProfileService); const configSnapshot = structuredClone(this.get(IConfigService).getAll() as KimiConfig); let wireHistory = await this.wireHistory(); let resumedThroughRecord = wireHistory.length; const resumed = createTestAgent( - { autoConfigure: false, cwd: profile.data().cwd }, + { autoConfigure: false, cwd: this.cwd }, ...this.serviceOverrides, configServices(() => configSnapshot), llmGenerateServices(failOnResumeGenerate), @@ -1854,6 +1891,37 @@ export class AgentTestContext { this.snapshots.respondPending(method, id, result); } + /** + * The harness AGENTS.md provider: no Workspace scope and no fs watch exist + * here, so `ready` re-reads the instruction files on every await and the + * getters expose the freshest load — the same freshness the old per-prompt + * disk read gave prompt builds. + */ + private createInstructionsProvider(): ISessionInstructionsProvider { + const fs = this.root.accessor.get(IHostFileSystem); + const env = this.root.accessor.get(IHostEnvironment); + const bootstrapService = this.root.accessor.get(IBootstrapService); + const cwd = this.cwd; + let current: LoadedAgentsMd = { content: '', warning: undefined }; + return { + _serviceBrand: undefined, + get ready(): Promise { + return loadAgentsMdForRoots({ fs, homeDir: env.homeDir }, bootstrapService.homeDir, [ + cwd, + ]).then((result) => { + current = result; + }); + }, + get agentsMd() { + return current.content; + }, + get agentsMdWarning() { + return current.warning; + }, + onDidChange: Event.None as Event, + }; + } + private createInteractionService(): ISessionInteractionService { const pending = new Map(); function createTestInteraction( @@ -2106,8 +2174,8 @@ function createWorkspaceContextStub( initialWorkDir: string, initialAdditionalDirs: readonly string[], ): ISessionWorkspaceContext { - let workDir = resolve(initialWorkDir); - let additionalDirs = initialAdditionalDirs.map((dir) => resolve(dir)); + const workDir = resolve(initialWorkDir); + const additionalDirs = initialAdditionalDirs.map((dir) => resolve(dir)); const isWithin = (absPath: string): boolean => { const target = resolve(absPath); if (target === workDir) return true; @@ -2120,18 +2188,8 @@ function createWorkspaceContextStub( }; return { _serviceBrand: undefined, - get workDir() { - return workDir; - }, - get additionalDirs() { - return additionalDirs; - }, - setWorkDir: (next) => { - workDir = resolve(next); - }, - setAdditionalDirs: (dirs) => { - additionalDirs = dirs.map((dir) => resolve(dir)); - }, + workDir, + additionalDirs, resolve: (path) => (isAbsolute(path) ? resolve(path) : resolve(workDir, path)), isWithin, assertAllowed: (absPath: string, op: PathAccessOperation) => { @@ -2141,14 +2199,6 @@ function createWorkspaceContextStub( } return target; }, - addAdditionalDir: (dir) => { - const resolved = resolve(dir); - if (!additionalDirs.includes(resolved)) additionalDirs = [...additionalDirs, resolved]; - }, - removeAdditionalDir: (dir) => { - const resolved = resolve(dir); - additionalDirs = additionalDirs.filter((candidate) => candidate !== resolved); - }, }; } @@ -2324,7 +2374,7 @@ function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config const providerConfig = model === undefined ? undefined : ctx.get(IProviderService).get(model.providerName); return { - cwd: data.cwd, + cwd: ctx.get(ISessionContext).cwd, activeToolNames: data.activeToolNames, provider: providerConfig, profileName: data.profileName, diff --git a/packages/agent-core-v2/test/agent/mcp/client-http.test.ts b/packages/agent-core-v2/test/mcpCore/client-http.test.ts similarity index 99% rename from packages/agent-core-v2/test/agent/mcp/client-http.test.ts rename to packages/agent-core-v2/test/mcpCore/client-http.test.ts index f10dfebc6..2d7d5775e 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-http.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-http.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ErrorCodes, Error2 } from '#/errors'; -import { buildMcpHttpHeaders, HttpMcpClient, isTerminalTransportError } from '#/agent/mcp/client-http'; +import { buildMcpHttpHeaders, HttpMcpClient, isTerminalTransportError } from '#/mcpCore/client-http'; import { startInProcessHttpMcpServer } from './stubs'; diff --git a/packages/agent-core-v2/test/agent/mcp/client-sse.test.ts b/packages/agent-core-v2/test/mcpCore/client-sse.test.ts similarity index 96% rename from packages/agent-core-v2/test/agent/mcp/client-sse.test.ts rename to packages/agent-core-v2/test/mcpCore/client-sse.test.ts index 378619b6d..1c98d1ee7 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-sse.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-sse.test.ts @@ -1,7 +1,7 @@ import { SseError } from '@modelcontextprotocol/sdk/client/sse.js'; import { afterEach, describe, expect, it } from 'vitest'; -import { SseMcpClient, isTerminalSseTransportError } from '#/agent/mcp/client-sse'; +import { SseMcpClient, isTerminalSseTransportError } from '#/mcpCore/client-sse'; import { startInProcessSseMcpServer } from './stubs'; diff --git a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts similarity index 99% rename from packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts rename to packages/agent-core-v2/test/mcpCore/client-stdio.test.ts index 959488431..7092f0428 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -5,7 +5,7 @@ import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { Error2 } from '#/errors'; -import { mergeStdioEnv, StdioMcpClient } from '#/agent/mcp/client-stdio'; +import { mergeStdioEnv, StdioMcpClient } from '#/mcpCore/client-stdio'; import { crashAfterConnectFixture, diff --git a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts similarity index 85% rename from packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts rename to packages/agent-core-v2/test/mcpCore/connection-manager.test.ts index fe561192f..c6bd4f92a 100644 --- a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts +++ b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts @@ -1,10 +1,11 @@ /** - * Scenario: MCP connection lifecycle, timeout defaults, and session config readiness. + * Scenario: MCP connection lifecycle and timeout defaults. * - * Exercises the real connection manager and resolves the real session MCP - * service through DI. Stdio MCP processes are the external boundary; timeout - * forwarding tests stub only the MCP SDK client boundary. - * Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Exercises the real connection manager; stdio MCP processes are the + * external boundary, and timeout forwarding tests stub only the MCP SDK + * client boundary. The workspace-level initialization scenarios live in + * `test/workspace/workspaceMcp/`. Run with + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/agent/mcp/connection-manager.test.ts`. */ @@ -28,23 +29,10 @@ import type { import { z } from 'zod'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices } from '#/_base/di/test'; -import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; -import { McpConnectionManager, type McpServerEntry } from '#/agent/mcp/connection-manager'; -import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; -import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import { SessionMcpService } from '#/session/mcp/sessionMcpService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { McpConnectionManager, type McpServerEntry } from '#/mcpCore/connection-manager'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; -import { stubLog } from '../../_base/log/stubs'; import { closeServer, crashAfterConnectFixture, @@ -830,96 +818,3 @@ describe('McpConnectionManager', () => { } }, 15000); }); - -describe('Session MCP initialization', () => { - let cwd: string; - let homeDir: string; - let disposables: DisposableStore; - let manager: McpConnectionManager | undefined; - - beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); - homeDir = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-home-')); - disposables = new DisposableStore(); - manager = undefined; - }); - - afterEach(async () => { - await manager?.shutdown(); - disposables.dispose(); - await Promise.all([ - rm(cwd, { recursive: true, force: true }), - rm(homeDir, { recursive: true, force: true }), - ]); - }); - - function createSessionMcpService(ready: Promise, mcpSection?: McpSection) { - const ix = createServices(disposables, { - strict: true, - additionalServices: (reg) => { - reg.definePartialInstance(IBootstrapService, { homeDir }); - reg.definePartialInstance(ISessionWorkspaceContext, { workDir: cwd }); - reg.definePartialInstance(IPluginService, { - enabledMcpServers: async () => ({}), - }); - reg.definePartialInstance(IAtomicDocumentStore, {}); - reg.defineInstance(ILogService, stubLog()); - reg.defineInstance(ITelemetryService, noopTelemetryService); - reg.definePartialInstance(IConfigService, { - ready, - get: ((domain: string): T => - (domain === MCP_SECTION ? mcpSection : undefined) as T), - }); - reg.define(ISessionMcpService, SessionMcpService); - }, - }); - return ix.get(ISessionMcpService); - } - - it('exposes the connection manager before config is ready and starts connecting once ready', async () => { - let resolveConfigReady!: () => void; - const ready = new Promise((resolve) => { - resolveConfigReady = resolve; - }); - const service = createSessionMcpService(ready); - // The manager is available synchronously, independent of config readiness. - manager = service.connectionManager(); - expect(manager.list()).toEqual([]); - - const initialLoad = service.ensureMcpReady({ - alpha: { - transport: 'stdio', - command: process.execPath, - args: [stdioFixture], - }, - }); - try { - // The initial connect is gated on config.ready: no entry exists yet. - await sleep(50); - expect(manager.list()).toEqual([]); - - resolveConfigReady(); - await initialLoad; - expect(manager.get('alpha')?.status).toBe('connected'); - } finally { - resolveConfigReady(); - await initialLoad; - } - }, 15000); - - it('times out tool calls using the session MCP timeout preference', async () => { - const service = createSessionMcpService(Promise.resolve(), { toolTimeoutMs: 1 }); - await service.ensureMcpReady({ - slowTool: { - transport: 'stdio', - command: process.execPath, - args: [slowToolStdioFixture], - env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, - }, - }); - manager = service.connectionManager(); - const client = manager.resolved('slowTool')?.client; - if (client === undefined) throw new Error('expected a connected client'); - await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); - }, 15000); -}); diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/crash-after-connect-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/crash-after-connect-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/crash-after-connect-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/crash-after-connect-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/cwd-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/cwd-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/cwd-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/cwd-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/hanging-list-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/hanging-list-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/hanging-list-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/hanging-list-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/mock-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/mock-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/mock-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/mock-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/slow-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/slow-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/slow-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/slow-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/slow-tool-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/slow-tool-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/stderr-then-exit-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/stderr-then-exit-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/stderr-then-exit-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/stderr-then-exit-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts b/packages/agent-core-v2/test/mcpCore/oauth/store.test.ts similarity index 69% rename from packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts rename to packages/agent-core-v2/test/mcpCore/oauth/store.test.ts index c7046a29d..b0d78e8db 100644 --- a/packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts +++ b/packages/agent-core-v2/test/mcpCore/oauth/store.test.ts @@ -1,14 +1,9 @@ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import { describe, expect, it } from 'vitest'; -import { McpOAuthClientProvider } from '#/agent/mcp/oauth/provider'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { - createMcpOAuthStore, - mcpOAuthStoreKey, - sanitizeStoreKey, -} from '#/agent/mcp/oauth/store'; -import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { McpOAuthClientProvider } from '#/mcpCore/oauth/provider'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { mcpOAuthStoreKey, sanitizeStoreKey } from '#/mcpCore/oauth/store'; import { createMemoryMcpOAuthStore } from '../stubs'; @@ -31,47 +26,6 @@ describe('sanitizeStoreKey', () => { }); }); -describe('createMcpOAuthStore', () => { - it('round-trips JSON data through the credentials/mcp scope', async () => { - const calls: Array<{ op: string; scope: string; key: string; value?: unknown }> = []; - const docs: Pick = { - async get(scope: string, key: string): Promise { - calls.push({ op: 'get', scope, key }); - return { hello: 'world' } as T; - }, - async set(scope, key, value) { - calls.push({ op: 'set', scope, key, value }); - }, - async delete(scope, key) { - calls.push({ op: 'delete', scope, key }); - }, - }; - const store = createMcpOAuthStore(docs as unknown as IAtomicDocumentStore); - - await expect(store.read('foo.json')).resolves.toEqual({ hello: 'world' }); - await store.write('foo.json', { token: 'abc' }); - await store.remove('foo.json'); - - expect(calls).toEqual([ - { op: 'get', scope: 'credentials/mcp', key: 'foo.json' }, - { op: 'set', scope: 'credentials/mcp', key: 'foo.json', value: { token: 'abc' } }, - { op: 'delete', scope: 'credentials/mcp', key: 'foo.json' }, - ]); - }); - - it('returns undefined when the underlying document store read fails', async () => { - const store = createMcpOAuthStore({ - get: async () => { - throw new Error('corrupt json'); - }, - set: async () => {}, - delete: async () => {}, - } as unknown as IAtomicDocumentStore); - - await expect(store.read('bad.json')).resolves.toBeUndefined(); - }); -}); - describe('MCP OAuth credential identity', () => { it('isolates tokens for the same server name on different URLs', async () => { const store = createMemoryMcpOAuthStore(); diff --git a/packages/agent-core-v2/test/agent/mcp/stubs.ts b/packages/agent-core-v2/test/mcpCore/stubs.ts similarity index 98% rename from packages/agent-core-v2/test/agent/mcp/stubs.ts rename to packages/agent-core-v2/test/mcpCore/stubs.ts index 4dea9aa1d..2e11f25c7 100644 --- a/packages/agent-core-v2/test/agent/mcp/stubs.ts +++ b/packages/agent-core-v2/test/mcpCore/stubs.ts @@ -8,8 +8,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import type { Tool as KosongTool } from '#/kosong/contract/tool'; import { z } from 'zod'; -import type { McpOAuthStore } from '#/agent/mcp/oauth/store'; -import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types'; +import type { McpOAuthStore } from '#/mcpCore/oauth/store'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import type { ExecutableTool, ExecutableToolContext, diff --git a/packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts b/packages/agent-core-v2/test/mcpCore/tool-naming.test.ts similarity index 94% rename from packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts rename to packages/agent-core-v2/test/mcpCore/tool-naming.test.ts index 815f22e38..76466e507 100644 --- a/packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts +++ b/packages/agent-core-v2/test/mcpCore/tool-naming.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { isMcpToolName, qualifyMcpToolName, sanitizeMcpNamePart } from '#/agent/mcp/tool-naming'; +import { isMcpToolName } from '#/tool/toolContract'; +import { qualifyMcpToolName, sanitizeMcpNamePart } from '#/mcpCore/tool-naming'; describe('sanitizeMcpNamePart', () => { it('passes alphanumeric, underscore, and dash through unchanged', () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 868354b4a..85f0e06b6 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -32,6 +32,8 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { Event } from '#/_base/event'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { type GrepInput, @@ -311,6 +313,11 @@ describe('GrepTool', () => { reg.define(IGrepTool, ProductionGrepTool); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolActivationService, AgentToolActivationService); + reg.defineInstance(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: [], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); reg.definePartialInstance(IAgentProfileService, { data: () => ({}) as unknown as ProfileData, }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 8ba39f86d..56e216d17 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -14,11 +14,10 @@ import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; -import { type McpServerConfig } from '#/agent/mcp/config-schema'; import { IAgentProfileService } from '#/agent/profile/profile'; import '#/agent/profile/profileService'; import { IAgentMcpService } from '#/agent/mcp/mcp'; -import { McpConnectionManager } from '#/agent/mcp/connection-manager'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import '#/agent/permissionMode/permissionModeOps'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -26,8 +25,10 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import { SessionMcpService } from '#/session/mcp/sessionMcpService'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { createMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; import '#/agent/mcp/mcpService'; @@ -35,7 +36,6 @@ import '#/wire/wireService'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import '#/agent/toolDedupe/toolDedupeService'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import '#/app/event/eventBusService'; @@ -58,6 +58,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { _clearAgentToolContributionsForTests } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import '#/agent/toolActivation/toolActivationService'; @@ -176,7 +177,11 @@ describe('AgentLifecycleService', () => { workspaceId: 'ws_test', sessionDir: '/tmp/kimi-agentLifecycle-test', metaScope: 'test', - }); + scope: (subKey?: string) => + subKey === undefined || subKey === '' + ? 'sessions/ws_test/sess_test' + : `sessions/ws_test/sess_test/${subKey}`, + } as unknown as ISessionContext); ix.stub(ISessionMetadata, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -191,10 +196,6 @@ describe('AgentLifecycleService', () => { _serviceBrand: undefined, homeDir: '/tmp/kimi-agentLifecycle-home', cwd: '/tmp/kimi-agentLifecycle-home', - agentScope: (_ws: string, _session: string, agentId: string) => - `test/agents/${agentId}`, - agentHomedir: (workspaceId: string, sessionId: string, agentId: string) => - `/tmp/kimi-agentLifecycle-home/sessions/${workspaceId}/${sessionId}/agents/${agentId}`, } as unknown as IBootstrapService); ix.stub(ISessionWorkspaceContext, { _serviceBrand: undefined, @@ -207,7 +208,7 @@ describe('AgentLifecycleService', () => { get: (() => undefined) as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); - ix.stub(IAtomicDocumentStore, { + const atomicDocsStore: IAtomicDocumentStore = { _serviceBrand: undefined, get: async (scope: string, key: string): Promise => atomicDocs.get(`${scope}/${key}`) as T | undefined, @@ -223,7 +224,8 @@ describe('AgentLifecycleService', () => { .map((key) => key.slice(scope.length + 1)), watch: () => Event.None as Event, acquire: () => ({ dispose: () => {} }), - } satisfies IAtomicDocumentStore); + }; + ix.stub(IAtomicDocumentStore, atomicDocsStore); ix.stub(ILogService, noopLog); ix.stub(IAgentPluginService, { _serviceBrand: undefined, @@ -328,6 +330,11 @@ describe('AgentLifecycleService', () => { disabledTools: () => [], setDisabledTools: () => Promise.resolve(), } as unknown as ISessionToolPolicy); + ix.stub(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: [], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); permissionModeSetMode = vi.fn(); ix.stub(IAgentPermissionModeService, { _serviceBrand: undefined, @@ -335,7 +342,25 @@ describe('AgentLifecycleService', () => { setMode: permissionModeSetMode, onDidChangeMode: Event.None, } as unknown as IAgentPermissionModeService); - ix.set(ISessionMcpService, new SyncDescriptor(SessionMcpService)); + ix.stub(ISessionInstructionsProvider, { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None as Event, + } satisfies ISessionInstructionsProvider); + // The session's MCP readiness arrives through the seeded + // `ISessionMcpHandle`; the default handle carries an OAuth-wired manager + // over the test atomic document store so the agent mirror's OAuth + // surface stays exercisable. + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: new McpConnectionManager({ + log: noopLog, + oauthService: new McpOAuthService({ store: createMcpOAuthStore(atomicDocsStore) }), + }), + } satisfies ISessionMcpHandle); stopAllOnExit = vi.fn(async () => []); ix.stub(IAgentTaskService, { _serviceBrand: undefined, @@ -617,39 +642,16 @@ describe('AgentLifecycleService', () => { ]); }); - it('waits for MCP config resolution and initial connect before returning an agent', async () => { - let resolvePluginServersRequested!: () => void; - const pluginServersRequested = new Promise((resolve) => { - resolvePluginServersRequested = resolve; + it('waits for the MCP handle readiness before returning an agent', async () => { + let releaseReady!: () => void; + const ready = new Promise((resolve) => { + releaseReady = resolve; }); - let resolvePluginServers: - | ((servers: Record) => void) - | undefined; - const pluginServers = new Promise>((resolve) => { - resolvePluginServers = resolve; - }); - ix.stub(IPluginService, { - ...pluginServiceStub, - enabledMcpServers: () => { - resolvePluginServersRequested(); - return pluginServers; - }, - } as unknown as IPluginService); - - let resolveConnectStarted!: () => void; - const connectStarted = new Promise((resolve) => { - resolveConnectStarted = resolve; - }); - let resolveConnect: (() => void) | undefined; - const connected = new Promise((resolve) => { - resolveConnect = resolve; - }); - const connectAll = vi - .spyOn(McpConnectionManager.prototype, 'connectAll') - .mockImplementation(() => { - resolveConnectStarted(); - return connected; - }); + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready, + connectionManager: new McpConnectionManager({ log: noopLog }), + } satisfies ISessionMcpHandle); const svc = ix.get(IAgentLifecycleService); let settled = false; @@ -657,49 +659,18 @@ describe('AgentLifecycleService', () => { settled = true; }); - await pluginServersRequested; - expect(settled).toBe(false); - expect(connectAll).not.toHaveBeenCalled(); - - resolvePluginServers?.({ - delayed: { transport: 'stdio', command: process.execPath }, + // The wire seal + registerAgent complete first; the create call then + // parks on the seeded MCP readiness promise. + await vi.waitFor(() => { + expect(registerAgent).toHaveBeenCalled(); }); - await connectStarted; - expect(connectAll).toHaveBeenCalledTimes(1); expect(settled).toBe(false); - resolveConnect?.(); + releaseReady(); await create; expect(settled).toBe(true); }); - it('merges caller-supplied MCP servers into the initial connect (file < caller < plugin)', async () => { - ix.stub(IPluginService, { - ...pluginServiceStub, - enabledMcpServers: async () => ({ - shared: { transport: 'stdio', command: 'plugin-version' }, - }), - } as unknown as IPluginService); - const connectAll = vi - .spyOn(McpConnectionManager.prototype, 'connectAll') - .mockResolvedValue(undefined); - - const sessionMcp = ix.get(ISessionMcpService); - await sessionMcp.ensureMcpReady({ - shared: { transport: 'stdio', command: 'caller-version' }, - callerOnly: { transport: 'http', url: 'https://caller.example.com' }, - }); - - expect(connectAll).toHaveBeenCalledTimes(1); - expect(connectAll).toHaveBeenCalledWith({ - shared: { transport: 'stdio', command: 'plugin-version' }, - callerOnly: { transport: 'http', url: 'https://caller.example.com' }, - }); - - await sessionMcp.ensureMcpReady({ ignored: { transport: 'stdio', command: 'ignored' } }); - expect(connectAll).toHaveBeenCalledTimes(1); - }); - it('exposes the in-flight handle and joins it after bootstrap', async () => { let releaseRegister!: () => void; let registerStarted!: () => void; @@ -766,7 +737,6 @@ describe('AgentLifecycleService', () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); source.accessor.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '/work', profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', @@ -778,7 +748,6 @@ describe('AgentLifecycleService', () => { const child = await svc.fork('main', { agentId: 'forked' }); expect(child.accessor.get(IAgentProfileService).data()).toMatchObject({ - cwd: '/work', profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', diff --git a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts similarity index 99% rename from packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts rename to packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts index 7e92fb462..26a01dd51 100644 --- a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts @@ -6,7 +6,7 @@ import { collectGitContext, parseProjectName, sanitizeRemoteUrl, -} from '#/session/sessionFs/gitContext'; +} from '#/session/agentLifecycle/profile/gitContext'; import type { ILogger } from '#/_base/log/log'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; diff --git a/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts new file mode 100644 index 000000000..2be71a2da --- /dev/null +++ b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts @@ -0,0 +1,138 @@ +/** + * Cron fire steer-turn context e2e: when a scheduled cron task fires and + * steers a NEW turn on the idle main agent, the provider request must carry + * the full conversation context — in particular the earlier CronCreate tool + * result (which holds the `id: ` line the model was told about). + * + * Wiring: testAgent harness with a scripted provider. The harness builds the + * Session scope with a stub `IAgentLifecycleService` (no `create`), so this + * test overrides it with a registry stub that resolves `main` to the harness + * agent and fires `onDidCreate` the way production does — that is what binds + * `SessionCronServiceImpl` to the main agent (cron tools + wire restore + * hook). The cron clock is file-driven (`clock: file:...`) and ticking is + * manual (`manualTick: true`) so the fire is deterministic. + * + * Run: ../../node_modules/.bin/vitest run test/session/cron/cron-fire-steer.e2e.test.ts + */ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Emitter, Event } from '#/_base/event'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import type { CronConfig } from '#/app/cron/configSection'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; + +import { createTestAgent, sessionService, type TestAgentContext } from '../../harness'; + +function textOf(message: ContextMessage): string { + return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +} + +describe('cron-fired steer turn context', () => { + let ctx: TestAgentContext; + let clockFile: string; + let onDidCreate: Emitter; + let mainHandle: IAgentScopeHandle | undefined; + + beforeEach(async () => { + const dir = mkdtempSync(join(tmpdir(), 'cron-steer-')); + clockFile = join(dir, 'clock.txt'); + writeFileSync(clockFile, String(Date.now())); + + onDidCreate = new Emitter(); + const lifecycleStub: IAgentLifecycleService = { + _serviceBrand: undefined, + onDidCreate: onDidCreate.event, + onDidDispose: Event.None as Event, + create: () => Promise.reject(new Error('not supported in this test')), + fork: () => Promise.reject(new Error('not supported in this test')), + get: (agentId) => (agentId === 'main' ? mainHandle : undefined), + list: () => (mainHandle === undefined ? [] : [mainHandle]), + broadcastPermissionMode: () => {}, + remove: () => Promise.resolve(), + }; + ctx = createTestAgent(sessionService(IAgentLifecycleService, lifecycleStub)); + + // Bind the cron service to the harness's main agent the way production + // does once AgentLifecycleService.create resolves the main handle. + const accessor = { + get: (id: ServiceIdentifier): T => ctx.get(id), + }; + mainHandle = { id: 'main', kind: LifecycleScope.Agent, accessor, dispose: () => {} }; + onDidCreate.fire(mainHandle); + + // Deterministic cron: file-driven wall clock, manual ticks, no jitter. + const cronConfig: CronConfig = { + debug: false, + noJitter: true, + noStale: false, + disabled: false, + manualTick: true, + clock: `file:${clockFile}`, + }; + // The harness KimiConfig index signature is readonly: replace the whole + // config object (the harness mutation idiom) instead of index-writing. + ctx.kimiConfig = { ...ctx.kimiConfig, cron: cronConfig }; + // Run the wire restore pipeline so the cron service's onDidRestore hook + // picks up the file clock and starts the (manual) scheduler. + await ctx.restorePersisted(); + + await ctx.rpc.setPermission({ mode: 'yolo' }); + }); + + afterEach(async () => { + onDidCreate.dispose(); + await ctx.dispose(); + }); + + it('carries earlier tool results into the cron-fired steer turn request', async () => { + ctx.mockNextResponse({ + type: 'function', + id: 'call_cron_1', + name: 'CronCreate', + arguments: JSON.stringify({ cron: '* * * * *', prompt: 'fire me', recurring: true }), + }); + ctx.mockNextResponse({ type: 'text', text: 'scheduled' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'remind me every minute' }] }); + await ctx.untilTurnEnd(); + + // Sanity: the CronCreate tool result landed in the conversation context. + const toolMessages = ctx.contextData().history.filter((m) => m.role === 'tool'); + expect(toolMessages).toHaveLength(1); + const jobId = textOf(toolMessages[0]!).match(/^id: (\S+)$/m)?.[1]; + expect(jobId).toBeDefined(); + + // Fire: push the wall clock past the next minute boundary and tick. + ctx.mockNextResponse({ type: 'text', text: 'cron turn done' }); + writeFileSync(clockFile, String(Date.now() + 120_000)); + await ctx.get(ISessionCronService).tick(); + await ctx.get(IAgentLoopService).settled(); + + // The steer turn ran exactly one more request. + expect(ctx.llmCalls.length).toBe(3); + const fireRequest = ctx.llmCalls.at(-1)!; + + // (1) The cron fire prompt is there as the latest user message. + const lastUser = fireRequest.history.filter((m) => m.role === 'user').at(-1); + const lastUserText = lastUser?.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join('') ?? ''; + expect(lastUserText).toContain('fire me'); + + // (2) The earlier CronCreate tool result is still in the request — + // this is the regression assertion. + const requestToolTexts = fireRequest.history + .filter((m) => m.role === 'tool') + .flatMap((m) => m.content) + .map((part) => (part.type === 'text' ? part.text : '')); + expect(requestToolTexts.some((text) => text.includes(`id: ${jobId!}`))).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index ccb7df17e..e3afb1922 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -1,718 +1,251 @@ /** - * Scenario: session agent-profile catalog — file-source discovery, priority - * merge, explicit fatal semantics, and config-driven reload. Exercises the - * real scoped catalog and source services against real temp directories. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Scenario: the Session-scope agent-profile catalog projection over the + * App-scope `IAgentProfileRegistry`. + * + * Exercises `SessionAgentProfileCatalogService` directly (no DI scope host): + * a hand-driven `AgentProfileRegistryService` plus a stub log verify the + * projection rules — relevant-entry filtering by the seeded workspace key, + * priority-ordered name dedup, the builtin-override rule, change-event + * fan-out, and the read surface (`get` / `list` / `getDefault` / `inspect`). + * Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts`. */ -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; -import { join } from 'pathe'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { - LifecycleScope, - ScopeActivation, - _clearScopedRegistryForTests, - registerScopedService, -} from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; import { DEFAULT_AGENT_PROFILE_NAME, - IAgentProfileCatalogService, + type AgentProfile, } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { AgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalogService'; -import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -import { EXTRA_AGENT_DIRS_SECTION } from '#/app/agentFileCatalog/configSection'; -import { - IUserFileAgentSource, - UserFileAgentSource, -} from '#/app/agentFileCatalog/userFileAgentSource'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import type { ReloadSummary } from '#/app/plugin/types'; -import '#/index'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - ExplicitFileAgentSource, - IExplicitFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; -import { - ExtraFileAgentSource, - IExtraFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; -import { - IPluginAgentProfileSource, - PluginAgentProfileSource, -} from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource'; -import { - IProjectFileAgentSource, - ProjectFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { AGENT_PROFILE_SOURCE_PRIORITY } from '#/app/agentProfileCatalog/agentProfileContribution'; -import { stubBootstrap } from '../../app/bootstrap/stubs'; +import { stubLog } from '../../_base/log/stubs'; -function configStub(): IConfigService & { - setExtraAgentDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; -} { - let extraAgentDirs: readonly string[] = []; - const sectionChangeListeners: Array<(event: unknown) => void> = []; +const WORKSPACE_KEY = 'wd_a'; + +function profile(name: string, options?: { readonly override?: boolean }): AgentProfile { return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; - }, - get: (domain: string) => - domain === EXTRA_AGENT_DIRS_SECTION ? [...extraAgentDirs] : undefined, - inspect: () => ({ - value: undefined, - defaultValue: undefined, - userValue: undefined, - memoryValue: undefined, - }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraAgentDirs: (dirs: readonly string[]) => { - extraAgentDirs = [...dirs]; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraAgentDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; + name, + override: options?.override, + systemPrompt: () => `prompt:${name}`, }; } -function workspaceStub(workDir: string): ISessionWorkspaceContext { - return { - _serviceBrand: undefined, - workDir, - additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - }; +function makeCatalog(workspaceKey: string = WORKSPACE_KEY) { + const registry = new AgentProfileRegistryService(); + const catalog = new SessionAgentProfileCatalogService( + registry, + { _serviceBrand: undefined, workspaceKey }, + stubLog(), + ); + return { registry, catalog }; } -function pluginStub( - agentRoots: readonly AgentFileRoot[] = [], - reloadEmitter?: Emitter, -): IPluginService { - return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => [], - pluginAgentRoots: async () => agentRoots, - enabledSessionStarts: async () => [], - enabledSystemPrompts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; -} +describe('SessionAgentProfileCatalogService (registry projection)', () => { + it('projects global entries and own-workspace entries, filtering other workspace keys', () => { + const { registry, catalog } = makeCatalog(); + const globalProfile = profile('global-p'); + const ownProfile = profile('own-ws-p'); + registry.register('user', { profiles: [globalProfile] }); + registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); + registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); -function agentMd(name: string, description: string, override = false): string { - const overrideLine = override ? 'override: true\n' : ''; - return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`; -} - -interface Fixture { - readonly homeDir: string; - readonly osHomeDir: string; - readonly workDir: string; - readonly extraDir: string; -} - -async function withFixture(run: (fixture: Fixture) => Promise): Promise { - const root = await mkdtemp(join(tmpdir(), 'agent-profile-catalog-')); - try { - const make = async (dir: string): Promise => { - const p = join(root, dir); - await mkdir(p, { recursive: true }); - return realpath(p); - }; - const [homeDir, osHomeDir, workDir, extraDir] = await Promise.all([ - make('kimi-home'), - make('os-home'), - make('work'), - make('extra-agents'), - ]); - await run({ homeDir, osHomeDir, workDir, extraDir }); - } finally { - await rm(root, { recursive: true, force: true }); - } -} - -async function writeAgent(dir: string, fileName: string, content: string): Promise { - await mkdir(dir, { recursive: true }); - const filePath = join(dir, fileName); - await writeFile(filePath, content); - return filePath; -} - -function logStub(warnings?: string[]): ILogService { - return { - _serviceBrand: undefined, - warn: (message: unknown) => { - warnings?.push(String(message)); - }, - info: () => {}, - error: () => {}, - debug: () => {}, - trace: () => {}, - setLevel: () => {}, - } as unknown as ILogService; -} - -function makeSession( - fixture: Fixture, - opts?: { - readonly extraAgentDirs?: readonly string[]; - readonly explicitFiles?: readonly string[]; - readonly logWarnings?: string[]; - readonly userSource?: IUserFileAgentSource; - readonly explicitSource?: IExplicitFileAgentSource; - readonly pluginAgentRoots?: readonly AgentFileRoot[]; - readonly pluginReloadEmitter?: Emitter; - }, -) { - const config = configStub(); - if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs); - const runtimeOptions = { - _serviceBrand: undefined, - explicitFiles: opts?.explicitFiles, - } as unknown as IAgentCatalogRuntimeOptions; - const host = createScopedTestHost([ - stubPair(IBootstrapService, { - ...stubBootstrap(fixture.homeDir), - osHomeDir: fixture.osHomeDir, - }), - stubPair(IConfigService, config), - stubPair(IAgentCatalogRuntimeOptions, runtimeOptions), - stubPair(ILogService, logStub()), - stubPair( - IPluginService, - pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter), - ), - ...(opts?.userSource ? [stubPair(IUserFileAgentSource, opts.userSource)] : []), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, workspaceStub(fixture.workDir)), - stubPair(ILogService, logStub(opts?.logWarnings)), - ...(opts?.explicitSource ? [stubPair(IExplicitFileAgentSource, opts.explicitSource)] : []), - ]); - return { host, session, config }; -} - -function waitForEvent(event: Event): Promise { - return new Promise((resolve) => { - const disposable = event(() => { - disposable.dispose(); - resolve(); - }); + expect(catalog.get('global-p')).toBe(globalProfile); + expect(catalog.get('own-ws-p')).toBe(ownProfile); + expect(catalog.get('other-ws-p')).toBeUndefined(); + catalog.dispose(); + registry.dispose(); }); -} -describe('SessionAgentProfileCatalogService', () => { - beforeEach(() => { - // `import '#/index'` fills the registry with the whole product graph, - // including OnScopeCreated services with unstubbed dependencies, so clear - // it and re-register only the real services this suite constructs; their - // other dependencies are seeded as stubs by `makeSession`. Builtin agent - // profile contributions accumulate in a separate module-level list at - // import time and are unaffected by the clear. - _clearScopedRegistryForTests(); - registerScopedService( - LifecycleScope.Session, - ISessionStateService, - SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', + it('excludes same-name profiles of other workspace keys from the merge entirely', () => { + const { registry, catalog } = makeCatalog(); + const userProfile = profile('shared'); + const ownProfile = profile('shared'); + registry.register('user', { profiles: [userProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.user, + }); + // A higher-priority entry from ANOTHER workspace must not even become a + // candidate: it neither wins the name nor shows up as suppressed. + registry.register('workspace', { profiles: [profile('shared')] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.explicit, + workspaceKey: 'wd_b', + }); + registry.register('workspace', { profiles: [ownProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get('shared')).toBe(ownProfile); + expect(catalog.inspect('shared')).toEqual({ + name: 'shared', + profile: ownProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [ + { sourceId: 'user', priority: AGENT_PROFILE_SOURCE_PRIORITY.user, reason: 'priority' }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('lets the higher-priority source win a name collision and reports the suppressed candidate', () => { + const { registry, catalog } = makeCatalog(); + const lowProfile = profile('x'); + const highProfile = profile('x'); + registry.register('user', { profiles: [lowProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.user, + }); + registry.register('workspace', { profiles: [highProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get('x')).toBe(highProfile); + expect(catalog.inspect('x')).toEqual({ + name: 'x', + profile: highProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [ + { sourceId: 'user', priority: AGENT_PROFILE_SOURCE_PRIORITY.user, reason: 'priority' }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('keeps the builtin profile when a same-name file profile lacks override: true', () => { + const { registry, catalog } = makeCatalog(); + const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const fileProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, + }); + registry.register('workspace', { profiles: [fileProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBe(builtinProfile); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: builtinProfile, + sourceId: BUILTIN_AGENT_PROFILE_SOURCE_ID, + priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, + suppressed: [ + { + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + reason: 'builtin-override-required', + }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('lets a file profile with override: true replace the same-name builtin', () => { + const { registry, catalog } = makeCatalog(); + const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const overrideProfile = profile(DEFAULT_AGENT_PROFILE_NAME, { override: true }); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }); + registry.register('workspace', { profiles: [overrideProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBe(overrideProfile); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: overrideProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('re-projects and fires the source id on relevant registry changes, ignoring other keys', () => { + const { registry, catalog } = makeCatalog(); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + + const ownProfile = profile('own-ws-p'); + registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); + expect(catalog.get('own-ws-p')).toBe(ownProfile); + + const globalProfile = profile('global-p'); + registry.register('user', { profiles: [globalProfile] }); + expect(catalog.get('global-p')).toBe(globalProfile); + + // Changes tagged with another workspace key are ignored entirely: no + // re-projection, no event. + registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); + registry.unregister('workspace', 'wd_b'); + expect(catalog.get('other-ws-p')).toBeUndefined(); + + registry.unregister('user'); + expect(catalog.get('global-p')).toBeUndefined(); + + expect(seen).toEqual(['workspace', 'user', 'user']); + subscription.dispose(); + catalog.dispose(); + registry.dispose(); + }); + + it("fires 'catalog' on reload", async () => { + const { registry, catalog } = makeCatalog(); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + + await catalog.reload(); + + expect(seen).toEqual(['catalog']); + subscription.dispose(); + catalog.dispose(); + registry.dispose(); + }); + + it('resolves ready immediately (loader readiness is the workspace handler’s job)', async () => { + const { registry, catalog } = makeCatalog(); + + await expect(catalog.ready).resolves.toBeUndefined(); + await expect(catalog.load()).resolves.toBeUndefined(); + catalog.dispose(); + registry.dispose(); + }); + + it('serves the read surface and throws from getDefault without the default profile', () => { + const { registry, catalog } = makeCatalog(); + expect(catalog.get('missing')).toBeUndefined(); + expect(catalog.inspect('missing')).toBeUndefined(); + expect(catalog.list()).toEqual([]); + expect(() => catalog.getDefault()).toThrow( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, ); - registerScopedService( - LifecycleScope.App, - IAgentProfileCatalogService, - AgentProfileCatalogService, - ); - registerScopedService(LifecycleScope.App, IUserFileAgentSource, UserFileAgentSource); - registerScopedService(LifecycleScope.App, IHostFileSystem, HostFileSystem); - registerScopedService( - LifecycleScope.Session, - ISessionAgentProfileCatalog, - SessionAgentProfileCatalogService, - ); - registerScopedService( - LifecycleScope.Session, - IExplicitFileAgentSource, - ExplicitFileAgentSource, - ); - registerScopedService(LifecycleScope.Session, IExtraFileAgentSource, ExtraFileAgentSource); - registerScopedService( - LifecycleScope.Session, - IProjectFileAgentSource, - ProjectFileAgentSource, - ); - registerScopedService( - LifecycleScope.Session, - IPluginAgentProfileSource, - PluginAgentProfileSource, - ); - }); - it('lists builtin profiles when no agent directories exist', async () => { - await withFixture(async (fixture) => { - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); + const defaultProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const coderProfile = profile('coder'); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [defaultProfile] }); + registry.register('user', { profiles: [coderProfile] }); - expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); - expect(catalog.getDefault().name).toBe(DEFAULT_AGENT_PROFILE_NAME); - expect(catalog.list().length).toBeGreaterThan(0); - host.dispose(); - }); - }); - - it('merges user and project agents; project wins on name collision', async () => { - await withFixture(async (fixture) => { - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - await writeAgent(join(fixture.homeDir, 'agents'), 'user-only.md', agentMd('user-only', 'user agent')); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'shared.md', - agentMd('shared', 'from project'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from project'); - expect(catalog.get('user-only')?.description).toBe('user agent'); - host.dispose(); - }); - }); - - it('orders sources user < extra < project < explicit', async () => { - await withFixture(async (fixture) => { - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - await writeAgent(join(fixture.homeDir, 'agents'), 'user-extra.md', agentMd('user-extra', 'from user')); - await writeAgent(fixture.extraDir, 'shared.md', agentMd('shared', 'from extra')); - await writeAgent(fixture.extraDir, 'user-extra.md', agentMd('user-extra', 'from extra')); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'shared.md', - agentMd('shared', 'from project'), - ); - const explicitFile = await writeAgent( - fixture.workDir, - 'explicit.md', - agentMd('shared', 'from explicit'), - ); - const { host, session } = makeSession(fixture, { - extraAgentDirs: [fixture.extraDir], - explicitFiles: [explicitFile], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from explicit'); - expect(catalog.get('user-extra')?.description).toBe('from extra'); - host.dispose(); - }); - }); - - it('merges plugin agents below user; user wins on name collision', async () => { - await withFixture(async (fixture) => { - const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); - await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin')); - await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin')); - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - const { host, session } = makeSession(fixture, { - pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from user'); - expect(catalog.get('plugin-only')?.description).toBe('from plugin'); - host.dispose(); - }); - }); - - it('reloads the plugin source when plugins reload', async () => { - await withFixture(async (fixture) => { - const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); - await mkdir(pluginAgentsDir, { recursive: true }); - const reloadEmitter = new Emitter(); - const { host, session } = makeSession(fixture, { - pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], - pluginReloadEmitter: reloadEmitter, - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('late')).toBeUndefined(); - - await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent')); - const changed = waitForEvent(catalog.onDidChange); - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - await changed; - - expect(catalog.get('late')?.description).toBe('late plugin agent'); - host.dispose(); - }); - }); - - it('fails ready when an explicit agent file is invalid', async () => { - await withFixture(async (fixture) => { - const bad = await writeAgent( - fixture.workDir, - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - const { host, session } = makeSession(fixture, { explicitFiles: [bad] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await expect(catalog.load()).rejects.toThrow(/description/i); - host.dispose(); - }); - }); - - it('fails ready when an explicit agent file does not exist', async () => { - await withFixture(async (fixture) => { - const { host, session } = makeSession(fixture, { - explicitFiles: [join(fixture.workDir, 'missing.md')], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await expect(catalog.load()).rejects.toMatchObject({ code: 'os.fs.not_found' }); - host.dispose(); - }); - }); - - it('recovers ready after a reload fixes a previously fatal explicit file', async () => { - await withFixture(async (fixture) => { - const bad = await writeAgent( - fixture.workDir, - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - const { host, session } = makeSession(fixture, { explicitFiles: [bad] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await expect(catalog.load()).rejects.toThrow(/description/i); - - await writeFile(bad, agentMd('fixed', 'fixed agent')); - await catalog.reload(); - - await expect(catalog.load()).resolves.toBeUndefined(); - expect(catalog.get('fixed')?.description).toBe('fixed agent'); - host.dispose(); - }); - }); - - it('resolves relative explicit files against the session workDir', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, 'agents'), - 'solo.md', - agentMd('solo', 'relative explicit'), - ); - const { host, session } = makeSession(fixture, { explicitFiles: ['agents/solo.md'] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('solo')?.description).toBe('relative explicit'); - host.dispose(); - }); - }); - - it('reloads the extra source when extraAgentDirs changes', async () => { - await withFixture(async (fixture) => { - await writeAgent(fixture.extraDir, 'from-extra.md', agentMd('from-extra', 'extra agent')); - const { host, session, config } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('from-extra')).toBeUndefined(); - - config.setExtraAgentDirs([fixture.extraDir]); - const changed = waitForEvent(catalog.onDidChange); - config.fireSectionChange(EXTRA_AGENT_DIRS_SECTION); - await changed; - - expect(catalog.get('from-extra')?.description).toBe('extra agent'); - host.dispose(); - }); - }); - - it('skips invalid project files and still loads valid ones', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - await writeAgent(join(fixture.workDir, '.kimi-code', 'agents'), 'good.md', agentMd('good', 'valid')); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('good')?.description).toBe('valid'); - host.dispose(); - }); - }); - - it('keeps the builtin default when a same-name file does not opt in to override', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).not.toBe('project default override'); - host.dispose(); - }); - }); - - it('lets a file profile explicitly override the builtin default', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override', true), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('project default override'); - host.dispose(); - }); - }); - - it('falls back to a valid lower-priority builtin override', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.homeDir, 'agents'), - 'agent.md', - agentMd('agent', 'user default override', true), - ); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default without override'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('user default override'); - expect(catalog.getDefault().description).not.toBe('project default without override'); - host.dispose(); - }); - }); - - it('keeps builtin profiles and warns when a non-fatal source fails to load', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const failingUserSource = { - _serviceBrand: undefined, - id: 'user', - priority: 10, - load: () => Promise.reject(new Error('disk gone')), - } as unknown as IUserFileAgentSource; - const { host, session } = makeSession(fixture, { - logWarnings, - userSource: failingUserSource, - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await catalog.load(); - - expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); - expect(logWarnings.some((w) => w.includes('"user"'))).toBe(true); - host.dispose(); - }); - }); - - it('keeps the previous contribution when a source reload fails', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const emitter = new Emitter(); - let fail = false; - const fileProfile = { - name: 'file-agent', - description: 'from file', - systemPrompt: () => 'x', - }; - const userSource = { - _serviceBrand: undefined, - id: 'user', - priority: 10, - onDidChange: emitter.event, - load: () => - fail - ? Promise.reject(new Error('disk gone')) - : Promise.resolve({ profiles: [fileProfile] }), - } as unknown as IUserFileAgentSource; - const { host, session } = makeSession(fixture, { logWarnings, userSource }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('file-agent')?.description).toBe('from file'); - - fail = true; - emitter.fire(); - await vi.waitFor(() => { - expect(logWarnings.some((w) => w.includes('load failed'))).toBe(true); - }); - - expect(catalog.get('file-agent')?.description).toBe('from file'); - host.dispose(); - }); - }); - - it('warns and keeps stale data when a fatal source reload fails', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const emitter = new Emitter(); - let fail = false; - const explicitProfile = { - name: 'exp-agent', - description: 'explicit', - systemPrompt: () => 'x', - }; - const explicitSource = { - _serviceBrand: undefined, - id: 'explicit', - priority: 40, - fatal: true, - onDidChange: emitter.event, - load: () => - fail - ? Promise.reject(new Error('file deleted mid-session')) - : Promise.resolve({ profiles: [explicitProfile] }), - } as unknown as IExplicitFileAgentSource; - const { host, session } = makeSession(fixture, { logWarnings, explicitSource }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('exp-agent')?.description).toBe('explicit'); - - fail = true; - emitter.fire(); - await vi.waitFor(() => { - expect(logWarnings.some((w) => w.includes('reload failed'))).toBe(true); - }); - - expect(catalog.get('exp-agent')?.description).toBe('explicit'); - host.dispose(); - }); - }); - - it('replaces the builtin default system prompt with user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile( - join(fixture.homeDir, 'SYSTEM.md'), - 'You are a custom main agent. cwd=${cwd} unknown=${nope}', - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - const prompt = catalog.getDefault().systemPrompt({ cwd: '/work/dir' }); - expect(prompt).toContain('You are a custom main agent.'); - expect(prompt).toContain('cwd=/work/dir'); - expect(prompt).toContain('unknown=${nope}'); - host.dispose(); - }); - }); - - it('lets SYSTEM.md win over a same-name scanned user agent file', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.homeDir, 'agents'), - 'agent.md', - agentMd('agent', 'user agents dir default', true), - ); - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().systemPrompt({})).toContain('system md prompt'); - host.dispose(); - }); - }); - - it('lets a same-name project agent file win over user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override', true), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('project default override'); - expect(catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); - host.dispose(); - }); - }); - - it('lets an explicit agent file win over user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - const explicitFile = await writeAgent( - fixture.workDir, - 'explicit.md', - agentMd('agent', 'explicit default override', true), - ); - const { host, session } = makeSession(fixture, { explicitFiles: [explicitFile] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('explicit default override'); - expect(catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); - host.dispose(); + expect(catalog.getDefault()).toBe(defaultProfile); + expect(catalog.get('coder')).toBe(coderProfile); + expect(catalog.list()).toEqual([defaultProfile, coderProfile]); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: defaultProfile, + sourceId: BUILTIN_AGENT_PROFILE_SOURCE_ID, + priority: 0, + suppressed: [], }); + catalog.dispose(); + registry.dispose(); }); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts deleted file mode 100644 index 1889c456c..000000000 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * `sessionFsWatch` domain (L2) — verifies confinement to the declared subtree, - * workspace-relative path mapping, debounce coalescing, window truncation, - * `.gitignore` filtering and handle lifecycle, using a fake os watcher. - */ - -import { isAbsolute, join, relative, resolve } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LifecycleScope } from '#/_base/di/scope'; -import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - type HostFsChange, - type IHostFsWatchHandle, - IHostFsWatchService, -} from '#/os/interface/hostFsWatch'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { FsChangeEvent } from '#/session/sessionFs/fsWatch'; - -import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; -import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; - -const WORK_DIR = '/repo'; - -void SessionFsWatchService; - -function stubWorkspace(): ISessionWorkspaceContext { - return { - _serviceBrand: undefined, - workDir: WORK_DIR, - additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)), - isWithin: (abs) => { - const r = relative(WORK_DIR, abs); - return r === '' || (!r.startsWith('..') && !isAbsolute(r)); - }, - assertAllowed: (abs) => abs, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - }; -} - -interface FakeWatch { - readonly service: IHostFsWatchService; - readonly watchCalls: string[]; - fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; - readonly disposed: () => boolean; -} - -function fakeHostFsWatch(): FakeWatch { - const watchCalls: string[] = []; - let listener: ((e: HostFsChange) => void) | undefined; - let disposed = false; - const handle: IHostFsWatchHandle = { - onDidChange: (l) => { - listener = l; - return { dispose: () => (listener = undefined) }; - }, - dispose: () => { - disposed = true; - listener = undefined; - }, - }; - const service: IHostFsWatchService = { - _serviceBrand: undefined, - watch: (path) => { - watchCalls.push(path); - disposed = false; - return handle; - }, - }; - return { - service, - watchCalls, - fire: (rel, action, kind = 'file') => - listener?.({ path: join(WORK_DIR, rel), action, kind }), - disposed: () => disposed, - }; -} - -function fakeHostFs(gitignore?: string): IHostFileSystem { - return { - _serviceBrand: undefined, - readText: async (p: string) => { - if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore; - const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - }, - } as unknown as IHostFileSystem; -} - -interface Harness { - readonly svc: ISessionFsWatchService; - readonly watch: FakeWatch; - readonly events: FsChangeEvent[]; -} - -function makeSession(gitignore?: string): Harness { - const watch = fakeHostFsWatch(); - const host = createScopedTestHost(); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionStateService, new SessionStateService()), - stubPair(ISessionWorkspaceContext, stubWorkspace()), - stubPair(IHostFsWatchService, watch.service), - stubPair(IHostFileSystem, fakeHostFs(gitignore)), - ]); - const svc = session.accessor.get(ISessionFsWatchService); - const events: FsChangeEvent[] = []; - svc.onDidChangeFiles((e) => events.push(e)); - disposers.push(() => host.dispose()); - return { svc, watch, events }; -} - -const disposers: Array<() => void> = []; - -describe('SessionFsWatchService', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - for (const d of disposers.splice(0)) d(); - vi.useRealTimers(); - }); - - it('starts the os watcher on the workspace root for a non-empty subscription', () => { - const { svc, watch } = makeSession(); - svc.setWatchedPaths(['src']); - expect(watch.watchCalls).toEqual([WORK_DIR]); - expect(svc.watchedPaths).toEqual(['src']); - }); - - it('drops events outside the subscribed subtree', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['src']); - - watch.fire('src/a.ts', 'created'); - watch.fire('lib/b.ts', 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); - }); - - it('coalesces changes within a window into one event', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - - watch.fire('a.ts', 'created'); - watch.fire('b.ts', 'modified'); - watch.fire('c.ts', 'deleted'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.coalesced_window_ms).toBe(200); - expect(events[0]?.changes).toHaveLength(3); - }); - - it('marks the event truncated when the window overflows', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - - for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.truncated).toBe(true); - expect(events[0]?.changes).toEqual([]); - expect(events[0]?.count).toBe(501); - }); - - it('filters out `.gitignore`d paths once loaded', async () => { - const { svc, watch, events } = makeSession('dist/\n'); - svc.setWatchedPaths(['.']); - await Promise.resolve(); - await Promise.resolve(); - - watch.fire('dist/x.js', 'created'); - watch.fire('src/keep.ts', 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); - }); - - it('rejects paths that escape the workspace', () => { - const { svc } = makeSession(); - expect(() => svc.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/); - expect(() => svc.setWatchedPaths(['/abs'])).toThrowError(/rejected/); - }); - - it('disposes the os handle when the subscription set becomes empty', () => { - const { svc, watch } = makeSession(); - svc.setWatchedPaths(['src']); - expect(watch.disposed()).toBe(false); - svc.setWatchedPaths([]); - expect(watch.disposed()).toBe(true); - }); - - it('does not fire after the service is disposed', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - watch.fire('a.ts', 'created'); - (svc as unknown as { dispose: () => void }).dispose(); - vi.advanceTimersByTime(200); - expect(events).toHaveLength(0); - }); -}); diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index 044f5778e..85d256eee 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -16,6 +16,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionInitService } from '#/session/sessionInit/sessionInit'; import { SessionInitService } from '#/session/sessionInit/sessionInitService'; import { ISessionSubagentService } from '#/session/subagent/subagent'; @@ -68,7 +69,7 @@ describe('SessionInitService', () => { const eventBus = { publish: vi.fn((event: unknown) => events.push(event)) }; const telemetry = { track: vi.fn(), track2: vi.fn() }; const profile = { - data: () => ({ modelAlias: 'mock-model', thinkingLevel: 'off', cwd: WORK_DIR }), + data: () => ({ modelAlias: 'mock-model', thinkingLevel: 'off' }), }; const permissionMode = { mode: 'auto', setMode: vi.fn() }; @@ -122,6 +123,10 @@ describe('SessionInitService', () => { _serviceBrand: undefined, homeDir: '/home/brand', } as unknown as IBootstrapService); + ix.stub(ISessionContext, { + _serviceBrand: undefined, + cwd: WORK_DIR, + } as unknown as ISessionContext); ix.set(ISessionInitService, new SyncDescriptor(SessionInitService)); }); @@ -133,7 +138,7 @@ describe('SessionInitService', () => { expect(create).toHaveBeenCalledTimes(1); expect(create.mock.calls[0]![0]).toMatchObject({ - binding: { profile: 'coder', model: 'mock-model', thinking: 'off', cwd: WORK_DIR }, + binding: { profile: 'coder', model: 'mock-model', thinking: 'off' }, }); expect(run).toHaveBeenCalledTimes(1); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index d552fb6c0..840b05b20 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -1,787 +1,140 @@ /** - * Scenario: session skill-source discovery, merge, and plugin refresh. + * Scenario: the Session-scope skill-catalog view over the seeded workspace data. * - * Exercises the real scoped catalog and source services with filesystem or - * in-memory discovery boundaries, including controlled concurrent refreshes. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Exercises `SessionSkillCatalogService` against a controlled + * `ISessionSkillCatalogData` seed: snapshot forwarding, change-event + * fan-out, session-local ad-hoc sink contributions, and the no-rescan + * `reload()`. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/session/sessionSkillCatalog/skillCatalog.test.ts`. */ -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; - -import { join } from 'pathe'; import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { _clearScopedRegistryForTests, LifecycleScope, - ScopeActivation, registerScopedService, } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IPluginService } from '#/app/plugin/plugin'; -import { PluginService } from '#/app/plugin/pluginService'; -import type { ReloadSummary } from '#/app/plugin/types'; -import { IProviderService } from '#/kosong/provider/provider'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { IConfigService } from '#/app/config/config'; -import { - EXTRA_SKILL_DIRS_SECTION, - MERGE_ALL_AVAILABLE_SKILLS_SECTION, -} from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; -import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; -import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; -import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; -import type { SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { Emitter } from '#/_base/event'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import { ISessionSkillCatalog, type ISkillCatalogSink } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; -import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/session/sessionSkillCatalog/explicitFileSkillSource'; -import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/session/sessionSkillCatalog/extraFileSkillSource'; -import { IWorkspaceFileSkillSource, WorkspaceFileSkillSource } from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; -import { IPluginSkillSource, PluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import type { SkillRoot } from '#/app/skillCatalog/types'; -import { stubBootstrap } from '../../app/bootstrap/stubs'; import { stubSkill } from '../../app/skillCatalog/stubs'; -import { stubProviderService } from '../../app/provider/stubs'; -const bootstrapStub = stubBootstrap('/home'); - -function configStub(): IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; +function dataSeed(initial: InMemorySkillCatalog): { + readonly data: ISessionSkillCatalogData; + readonly changes: Emitter; + replace(next: InMemorySkillCatalog): void; } { - let extraSkillDirs: readonly string[] = []; - let mergeAllAvailableSkills = true; - const sectionChangeListeners: Array<(event: unknown) => void> = []; + let current: SkillCatalog = initial; + const changes = new Emitter(); return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; + changes, + data: { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: changes.event, + get catalog() { + return current; + }, }, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; - return undefined; + replace(next: InMemorySkillCatalog) { + current = next; }, - inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraSkillDirs: (dirs: readonly string[]) => { - extraSkillDirs = [...dirs]; - }, - setMergeAllAvailableSkills: (value: boolean) => { - mergeAllAvailableSkills = value; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; }; } -function pluginStub( - skillRoots: readonly SkillRoot[] = [], - reloadEmitter?: Emitter, -): IPluginService { - return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => skillRoots, - pluginAgentRoots: async () => [], - enabledSessionStarts: async () => [], - enabledSystemPrompts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; +function catalogOf(...skills: readonly ReturnType[]): InMemorySkillCatalog { + const catalog = new InMemorySkillCatalog(); + for (const skill of skills) catalog.register(skill, { replace: true }); + return catalog; } -function workspaceStub(workDir: string): { - readonly stub: ISessionWorkspaceContext; - setWorkDir(dir: string): void; -} { - let current = workDir; - const stub = { - _serviceBrand: undefined, - get workDir() { - return current; - }, - additionalDirs: [] as readonly string[], - setWorkDir: (dir: string) => { - current = dir; - }, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - } satisfies ISessionWorkspaceContext; - return { stub, setWorkDir: (dir) => { current = dir; } }; -} - -function makeHost( - store: ISkillDiscovery, - ws: ISessionWorkspaceContext, - pluginRoots: readonly SkillRoot[] = [], - explicitDirs?: readonly string[], - pluginReloadEmitter?: Emitter, -) { - const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - explicitDirs, - } as unknown as ISkillCatalogRuntimeOptions; - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - return { host, session, config }; -} - -function waitForEvents(event: Event, count: number): Promise { - return new Promise((resolve) => { - let received = 0; - const disposable = event(() => { - received += 1; - if (received === count) { - disposable.dispose(); - resolve(); - } - }); - }); -} - -function deferred(): { - readonly promise: Promise; - readonly resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -async function withSkillCatalogWorkspace( - run: (fixture: { readonly workDir: string; readonly skillRoot: string }) => Promise, -): Promise { - const workDir = await mkdtemp(join(tmpdir(), 'skill-catalog-')); - const skillRoot = join(workDir, '.kimi-code', 'skills'); - await mkdir(skillRoot, { recursive: true }); - try { - await run({ workDir, skillRoot: await realpath(skillRoot) }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -describe('SessionSkillCatalogService', () => { +describe('SessionSkillCatalogService (seed view)', () => { beforeEach(() => { - // Keep the scoped registry limited to the catalog chain these tests - // exercise so unrelated OnScopeCreated registrations do not run; every - // other dependency arrives as a seeded stub via `createScopedTestHost`. _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.Session, ISessionStateService, SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', ); - registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); - registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); - registerScopedService(LifecycleScope.App, IPluginService, PluginService); registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService); - registerScopedService(LifecycleScope.Session, IExplicitFileSkillSource, ExplicitFileSkillSource); - registerScopedService(LifecycleScope.Session, IExtraFileSkillSource, ExtraFileSkillSource); - registerScopedService(LifecycleScope.Session, IWorkspaceFileSkillSource, WorkspaceFileSkillSource); - registerScopedService(LifecycleScope.Session, IPluginSkillSource, PluginSkillSource); }); - it('merges global and project skills; project wins on name collision', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([ - stubSkill('global-only'), - stubSkill('shared', { description: 'from user' }), + function makeSession(data: ISessionSkillCatalogData) { + const host = createScopedTestHost([]); + const session = host.child(LifecycleScope.Session, 's1', [ + stubPair(ISessionSkillCatalogData, data), ]); - store.setProjectSkills([ - stubSkill('project-only'), - stubSkill('shared', { description: 'from project' }), - ]); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws); + return { host, catalog: session.accessor.get(ISessionSkillCatalog) }; + } + + it('exposes the seeded snapshot after ready', async () => { + const seed = dataSeed(catalogOf(stubSkill('from-workspace'))); + const { host, catalog } = makeSession(seed.data); - const catalog = session.accessor.get(ISessionSkillCatalog); await catalog.load(); - - const names = catalog.catalog.listSkills().map((s) => s.name); - expect(names).toContain('global-only'); - expect(names).toContain('project-only'); - expect(names).toContain('shared'); - expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); + expect(catalog.catalog.getSkill('from-workspace')).toBeDefined(); host.dispose(); }); - it('orders project, user and plugin skills as project > user > plugin', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([ - stubSkill('shared', { description: 'from user' }), - stubSkill('user-plugin', { description: 'from user' }), - ]); - store.setProjectSkills([stubSkill('shared', { description: 'from project' })]); - store.setExtraSkills([ - stubSkill('shared', { description: 'from extra', source: 'extra' }), - stubSkill('user-plugin', { description: 'from extra', source: 'extra' }), - stubSkill('extra-plugin', { description: 'from extra', source: 'extra' }), - ]); - store.setPluginSkills([ - stubSkill('shared', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - stubSkill('user-plugin', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - stubSkill('extra-plugin', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - ]); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session, config } = makeHost(store, ws, [pluginRoot]); - config.setExtraSkillDirs(['/']); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('re-folds and forwards the source id when the seed fires a change', async () => { + const seed = dataSeed(catalogOf(stubSkill('before'))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); - expect(catalog.catalog.getSkill('user-plugin')?.description).toBe('from user'); - expect(catalog.catalog.getSkill('extra-plugin')?.description).toBe('from extra'); - host.dispose(); - }); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + seed.replace(catalogOf(stubSkill('after'))); + seed.changes.fire('workspace'); - it('replaces default user and project discovery with explicitDirs', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('from-explicit', { description: 'from explicit' })]); - store.setProjectSkills([stubSkill('project-only', { description: 'from project' })]); - store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); - store.setPluginSkills([ - stubSkill('plugin-only', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - ]); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session, config } = makeHost(store, ws, [pluginRoot], ['/']); - config.setExtraSkillDirs(['/']); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit'); - expect(catalog.catalog.getSkill('project-only')).toBeUndefined(); - expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); - expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin'); - host.dispose(); - }); - - it('waits for config ready before loading extra skill dirs', async () => { - let markReady!: () => void; - let ready = false; - const configReady = new Promise((resolve) => { - markReady = () => { - ready = true; - resolve(); - }; - }); - const config = { - ...configStub(), - ready: configReady, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return ready ? ['/'] : []; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; - return undefined; - }, - } as unknown as IConfigService; - const store = new InMemorySkillDiscovery(); - store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - - const catalog = session.accessor.get(ISessionSkillCatalog); - let settled = false; - const loading = catalog.load().then(() => { - settled = true; - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(settled).toBe(false); - - markReady(); - await loading; - - expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); - host.dispose(); - }); - - it('reloads user and workspace sources when mergeAllAvailableSkills changes', async () => { - class CountingDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - calls = 0; - async discover() { - this.calls++; - return { skills: [], skipped: [], scannedRoots: [] }; - } - } - const store = new CountingDiscovery(); - const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - const afterLoad = store.calls; - - const reloaded = waitForEvents(catalog.onDidChange, 2); - config.fireSectionChange(MERGE_ALL_AVAILABLE_SKILLS_SECTION); - await reloaded; - - expect(store.calls).toBe(afterLoad + 2); - host.dispose(); - }); - - it('reload replaces project skills when the workDir changes', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('global-only')]); - store.setProjectSkills([stubSkill('first')]); - const { stub: ws, setWorkDir } = workspaceStub('/work1'); - const { host, session } = makeHost(store, ws); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - expect(catalog.catalog.getSkill('first')).toBeDefined(); - - setWorkDir('/work2'); - store.setProjectSkills([stubSkill('second')]); - const changes: string[] = []; - const subscription = catalog.onDidChange((sourceId) => changes.push(sourceId)); - await catalog.reload(); - - expect(catalog.catalog.getSkill('first')).toBeUndefined(); - expect(catalog.catalog.getSkill('second')).toBeDefined(); - expect(catalog.catalog.getSkill('global-only')).toBeDefined(); - expect(changes).toEqual(['catalog']); + expect(catalog.catalog.getSkill('before')).toBeUndefined(); + expect(catalog.catalog.getSkill('after')).toBeDefined(); + expect(seen).toEqual(['workspace']); subscription.dispose(); host.dispose(); }); - it('does not reload when the workDir is unchanged', async () => { - const store = new InMemorySkillDiscovery(); - store.setProjectSkills([stubSkill('first')]); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('merges ad-hoc sink contributions over the seed and drops them on remove', async () => { + const seed = dataSeed(catalogOf(stubSkill('shared', { description: 'from workspace' }))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - store.setProjectSkills([stubSkill('second')]); - await catalog.load(); + const sink = catalog as unknown as ISkillCatalogSink; + sink.set( + 'adhoc', + { skills: [stubSkill('shared', { description: 'from adhoc' }), stubSkill('adhoc-only')] }, + { priority: 40 }, + ); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from adhoc'); + expect(catalog.catalog.getSkill('adhoc-only')).toBeDefined(); - expect(catalog.catalog.getSkill('first')).toBeDefined(); - expect(catalog.catalog.getSkill('second')).toBeUndefined(); + sink.remove('adhoc'); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from workspace'); + expect(catalog.catalog.getSkill('adhoc-only')).toBeUndefined(); host.dispose(); }); - it('passes plugin skill roots to the store so plugin skills are discoverable', async () => { - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo', instructions: 'Use the demo tools.' }, - }; - class ExtraRootStore implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - receivedRoots: readonly SkillRoot[] | undefined; - async discover(roots: readonly SkillRoot[]) { - if (roots.some((root) => root.plugin !== undefined)) { - this.receivedRoots = roots; - } - const pluginSkills = roots - .filter((root) => root.plugin !== undefined) - .map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin })); - return { skills: pluginSkills, skipped: [], scannedRoots: [] }; - } - } - const store = new ExtraRootStore(); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws, [pluginRoot]); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('reload re-folds the current seed without rescanning and fires catalog', async () => { + const seed = dataSeed(catalogOf(stubSkill('one'))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - expect(store.receivedRoots).toEqual([pluginRoot]); - expect(catalog.catalog.getSkill('demo-skill')?.plugin?.id).toBe('demo'); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + // A silent seed swap (no change event) becomes visible through reload. + seed.replace(catalogOf(stubSkill('two'))); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + await catalog.reload(); + + expect(catalog.catalog.getSkill('two')).toBeDefined(); + expect(seen).toEqual(['catalog']); + subscription.dispose(); host.dispose(); }); - - it('feeds scanned roots from file sources into the merged catalog', async () => { - await withSkillCatalogWorkspace(async ({ workDir, skillRoot }) => { - class RootDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - async discover(roots: readonly SkillRoot[]) { - return { - skills: [], - skipped: [], - scannedRoots: roots - .filter((root) => root.source === 'project') - .map((root) => root.path), - }; - } - } - const { stub: ws } = workspaceStub(workDir); - const { host, session } = makeHost(new RootDiscovery(), ws); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(catalog.catalog.getSkillRoots()).toEqual([skillRoot]); - } finally { - host.dispose(); - } - }); - }); - - it('feeds skipped skills from file sources into the merged catalog', async () => { - await withSkillCatalogWorkspace(async ({ workDir }) => { - const skippedEntry = { - path: join(workDir, '.kimi-code', 'skills', 'bad', 'SKILL.md'), - type: 'nope', - reason: 'unsupported skill type "nope"', - }; - class SkippingDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - async discover(roots: readonly SkillRoot[]) { - const isProject = roots.some((root) => root.source === 'project'); - return { - skills: [], - skipped: isProject ? [skippedEntry] : [], - scannedRoots: [], - }; - } - } - const { stub: ws } = workspaceStub(workDir); - const { host, session } = makeHost(new SkippingDiscovery(), ws); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(catalog.catalog.getSkippedByPolicy()).toEqual([skippedEntry]); - } finally { - host.dispose(); - } - }); - }); - - it('fires onDidChange with the plugin source id after a plugin reload re-pulls plugin skills', async () => { - const store = new InMemorySkillDiscovery(); - store.setPluginSkills([ - stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), - ]); - const reloadEmitter = new Emitter(); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws, [pluginRoot], undefined, reloadEmitter); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); - - const refreshed = new Promise((resolve) => { - const d = catalog.onDidChange((sourceId) => { - d.dispose(); - resolve(sourceId); - }); - }); - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - - await expect(refreshed).resolves.toBe('plugin'); - } finally { - host.dispose(); - reloadEmitter.dispose(); - } - }); - - it('queues a plugin refresh during initial load so the refreshed contribution remains active', async () => { - const initialLoad = deferred(); - const refreshedLoad = deferred(); - const initialStarted = deferred(); - const refreshedStarted = deferred(); - const sourceChanges = new Emitter(); - let loadCount = 0; - const pluginSource: IPluginSkillSource = { - _serviceBrand: undefined, - id: 'plugin', - priority: 5, - onDidChange: sourceChanges.event, - load: () => { - loadCount += 1; - if (loadCount === 1) { - initialStarted.resolve(undefined); - return initialLoad.promise; - } - if (loadCount === 2) { - refreshedStarted.resolve(undefined); - return refreshedLoad.promise; - } - throw new Error('unexpected plugin source load'); - }, - }; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - stubPair(IPluginSkillSource, pluginSource), - ]); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - const loading = catalog.load(); - await initialStarted.promise; - const sourceIds: string[] = []; - const refreshed = new Promise((resolve) => { - const subscription = catalog.onDidChange((sourceId) => { - sourceIds.push(sourceId); - if (sourceId === 'plugin') { - subscription.dispose(); - resolve(); - } - }); - }); - - sourceChanges.fire(); - - expect(loadCount).toBe(1); - - initialLoad.resolve({ - skills: [ - stubSkill('stale-skill', { source: 'extra', plugin: { id: 'demo' } }), - ], - }); - await refreshedStarted.promise; - refreshedLoad.resolve({ - skills: [ - stubSkill('fresh-skill', { source: 'extra', plugin: { id: 'demo' } }), - ], - }); - await Promise.all([loading, refreshed]); - - expect(sourceIds).toEqual(['plugin']); - expect(catalog.catalog.getPluginSkill('demo', 'stale-skill')).toBeUndefined(); - expect(catalog.catalog.getPluginSkill('demo', 'fresh-skill')).toBeDefined(); - } finally { - host.dispose(); - sourceChanges.dispose(); - } - }); - - it('binds thisArg when forwarding plugin reloads through the plugin skill source', async () => { - const reloadEmitter = new Emitter(); - const pluginService = pluginStub([], reloadEmitter); - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IPluginService, pluginService), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - ]); - - try { - const source = session.accessor.get(IPluginSkillSource); - void source.id; - const receiver = { tag: 'receiver' }; - const seen: unknown[] = []; - const subscription = source.onDidChange?.( - function (this: unknown) { - seen.push(this); - }, - receiver, - ); - - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - - expect(seen).toEqual([receiver]); - subscription?.dispose(); - } finally { - host.dispose(); - reloadEmitter.dispose(); - } - }); - - it('keeps non-plugin skills working and recovers plugin skills after a corrupt installed.json is fixed and reloaded', async () => { - const homeDir = await mkdtemp(join(tmpdir(), 'plugin-home-')); - await mkdir(join(homeDir, 'plugins'), { recursive: true }); - await writeFile(join(homeDir, 'plugins', 'installed.json'), '{ not json', 'utf8'); - - const managedRoot = join(homeDir, 'plugins', 'managed', 'demo'); - await mkdir(join(managedRoot, 'skills', 'demo-skill'), { recursive: true }); - await writeFile( - join(managedRoot, 'kimi.plugin.json'), - JSON.stringify({ name: 'demo', skills: './skills/' }), - 'utf8', - ); - await writeFile( - join(managedRoot, 'skills', 'demo-skill', 'SKILL.md'), - '---\nname: demo-skill\ndescription: demo\n---\nbody', - 'utf8', - ); - - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('global-only')]); - store.setPluginSkills([ - stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), - ]); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, stubBootstrap(homeDir)), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IProviderService, stubProviderService()), - ]); - const { stub: ws } = workspaceStub('/work'); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - ]); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - expect(catalog.catalog.getSkill('global-only')).toBeDefined(); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeUndefined(); - - await writeFile( - join(homeDir, 'plugins', 'installed.json'), - JSON.stringify({ - version: 1, - plugins: [ - { - id: 'demo', - root: managedRoot, - source: 'local-path', - enabled: true, - installedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - ], - }), - 'utf8', - ); - const refreshed = new Promise((resolve) => { - const d = catalog.onDidChange((sourceId) => { - if (sourceId === 'plugin') { - d.dispose(); - resolve(); - } - }); - }); - await host.app.accessor.get(IPluginService).reloadPlugins(); - await refreshed; - - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); - } finally { - host.dispose(); - await rm(homeDir, { recursive: true, force: true }); - } - }); }); diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 85721a0a4..dabeba532 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -1013,7 +1013,6 @@ describe('SessionSwarmService metadata compatibility', () => { profile: 'coder', model: 'kimi-test', thinking: 'medium', - cwd: '/repo', }, labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' }, }), @@ -1039,7 +1038,6 @@ describe('SessionSwarmService metadata compatibility', () => { profileName: opts.binding?.profile ?? 'coder', modelAlias: opts.binding?.model ?? 'kimi-test', thinkingLevel: opts.binding?.thinking ?? 'medium', - cwd: opts.binding?.cwd ?? '/repo', }, new Map([[IAgentUserToolService, childUserTools]]), ); @@ -1126,7 +1124,6 @@ describe('SessionSwarmService metadata compatibility', () => { profile: 'coder', model: 'provider/secondary', thinking: 'low', - cwd: '/repo', }, }), ); @@ -1299,7 +1296,6 @@ function lifecycleStub( profileName: opts.binding?.profile ?? 'coder', modelAlias: opts.binding?.model ?? 'kimi-test', thinkingLevel: opts.binding?.thinking ?? 'medium', - cwd: opts.binding?.cwd ?? '/repo', }); handles.set(id, handle); return handle; @@ -1337,7 +1333,6 @@ function agentHandle( services: ReadonlyMap = new Map(), ): IAgentScopeHandle { const profile = profileService({ - cwd: '/repo', modelAlias: 'kimi-test', modelCapabilities: {} as never, profileName: 'agent', diff --git a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts index 39f323acc..d71d8c3c1 100644 --- a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts +++ b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts @@ -73,13 +73,9 @@ function stubWorkspace(workDir = '/ws'): ISessionWorkspaceContext { _serviceBrand: undefined, workDir, additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, resolve: (rel) => resolve(workDir, rel), isWithin: () => true, assertAllowed: (absPath) => resolve(workDir, absPath), - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, }; } diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts deleted file mode 100644 index dd64ee7c1..000000000 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -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 } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { ErrorCodes, Error2 } from '#/errors'; -import { - type HostDirEntry, - type HostFileStat, - IHostFileSystem, -} from '#/os/interface/hostFileSystem'; -import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionWorkspaceCommandService } from '#/session/workspaceCommand/workspaceCommand'; -import { SessionWorkspaceCommandService } from '#/session/workspaceCommand/workspaceCommandService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; - -import { stubContextMemory, type StubContextMemory } from '../../agent/contextMemory/stubs'; -import { registerStateServices } from '../../state/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 danglingSymlinks = new Set(); - readonly statErrors = new Map(); - readonly readErrors = new Map(); - 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 error = this.readErrors.get(path); - if (error !== undefined) throw error; - 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); - } - - async appendText(path: string, data: string): Promise { - this.files.set(path, (this.files.get(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 { - yield* []; - throw new Error('not implemented'); - } - - async createExclusive(): Promise { - throw new Error('not implemented'); - } - - async stat(path: string): Promise { - const error = this.statErrors.get(path); - if (error !== undefined) throw error; - if (this.danglingSymlinks.has(path)) throw enoent(path); - 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 lstat(path: string): Promise { - if (this.danglingSymlinks.has(path)) { - return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0 }; - } - return this.stat(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); - } - - async realpath(path: string): Promise { - if (this.files.has(path) || this.dirs.has(path)) return path; - throw enoent(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 created = 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: created.event, - onDidDispose: () => ({ dispose: () => {} }), - create: () => Promise.reject(new Error('not implemented')), - fork: () => Promise.reject(new Error('not implemented')), - get: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined), - list: () => [], - remove: () => Promise.resolve(), - broadcastPermissionMode: () => {}, - setMain: (present) => { - mainPresent = present; - if (present) created.fire(mainHandle); - }, - }; -} - -function bootstrapStub(): IBootstrapService { - return { - _serviceBrand: undefined, - homeDir: '/home/test', - osHomeDir: '/users/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) => { - registerStateServices(reg); - reg.defineInstance(ISessionContext, ctx); - reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService); - reg.defineInstance(IBootstrapService, bootstrapStub()); - reg.defineInstance(IHostFileSystem, fs); - reg.define(IProjectLocalConfigService, FileProjectLocalConfigService); - 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('keeps a dangling .git symlink as the local project-root marker', async () => { - const ancestorRoot = '/repo/project'; - const workDir = `${ancestorRoot}/apps/foo`; - const sharedDir = `${workDir}/shared`; - const { svc, fs } = build([sharedDir], true, workDir, `${ancestorRoot}/.git`); - fs.danglingSymlinks.add(`${workDir}/.git`); - - const result = await svc.addAdditionalDir({ path: 'shared', persist: true }); - - expect(result.projectRoot).toBe(workDir); - expect(result.configPath).toBe(`${workDir}/.kimi-code/local.toml`); - expect(fs.files.get(`${workDir}/.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('expands home-relative dirs against the OS home like v1', async () => { - const homeDir = '/users/test/shared'; - const { svc, workspace } = build([homeDir], true); - - const result = await svc.addAdditionalDir({ path: '~/shared', persist: false }); - - expect(result.additionalDirs).toEqual([homeDir]); - expect(workspace.additionalDirs).toEqual([homeDir]); - }); - - it('treats project-root stat errors as absent git markers like v1', async () => { - const projectRoot = '/repo'; - const { svc, fs } = build([EXTRA_DIR], true, WORK_DIR, `${projectRoot}/.git`); - const error = new Error('EACCES: cannot stat .git') as NodeJS.ErrnoException; - error.code = 'EACCES'; - fs.statErrors.set(`${WORK_DIR}/.git`, error); - - const result = await svc.addAdditionalDir({ path: 'extra', persist: true }); - - expect(result.projectRoot).toBe(projectRoot); - expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`); - }); - - 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 Error2 && error.code === ErrorCodes.CONFIG_INVALID, - ); - }); - - it('surfaces a config read IO failure as storage.io_failed', async () => { - const { svc, fs } = build([EXTRA_DIR], true); - const error = new Error('EACCES: permission denied') as NodeJS.ErrnoException; - error.code = 'EACCES'; - fs.readErrors.set(`${WORK_DIR}/.kimi-code/local.toml`, error); - - await expect(svc.addAdditionalDir({ path: 'extra', persist: true })).rejects.toMatchObject({ - code: 'storage.io_failed', - }); - }); - - it('surfaces invalid TOML in the config as storage.decode_failed', async () => { - const { svc, fs } = build([EXTRA_DIR], true); - fs.files.set(`${WORK_DIR}/.kimi-code/local.toml`, 'not [valid toml'); - - await expect(svc.addAdditionalDir({ path: 'extra', persist: true })).rejects.toMatchObject({ - code: 'storage.decode_failed', - }); - }); -}); diff --git a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts index 3a4fe4b52..f563c3b4c 100644 --- a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts +++ b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts @@ -8,12 +8,8 @@ export function stubWorkspaceContext( _serviceBrand: undefined, workDir, additionalDirs, - setWorkDir: () => {}, - setAdditionalDirs: () => {}, resolve: (rel) => `${workDir}/${rel}`, isWithin: () => true, assertAllowed: (absPath) => absPath, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, }; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index bdb9de094..04d3c5b38 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -186,6 +186,7 @@ function profileCatalogWithPreference( get: (name) => [main, target].find((profile) => profile.name === name), getDefault: () => main, list: () => [target], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -238,7 +239,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen const servicesByAgentId = new Map(options.handleServices); const handle = (agentId: string): IAgentScopeHandle => ({ id: agentId, - kind: 2, + kind: LifecycleScope.Agent, accessor: { get: (serviceId) => { const service = servicesByAgentId.get(agentId)?.get(serviceId); @@ -586,6 +587,7 @@ describe('Agent tool description', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => restricted, list: () => profiles, + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -623,6 +625,7 @@ describe('Agent tool description', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -658,12 +661,12 @@ describe('Agent tool description', () => { get: (name) => [caller, coder, explore].find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; ctx = createTestAgent(sessionService(ISessionAgentProfileCatalog, catalog)); ctx.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '', profileName: 'deleted-profile', thinkingLevel: 'off', systemPrompt: 'persisted prompt', @@ -803,6 +806,7 @@ describe('Agent tool execution contract', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -834,7 +838,6 @@ describe('Agent tool execution contract', () => { sessionService(ISessionAgentProfileCatalog, allowlistCatalog(['coder'])), ); context.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '', profileName: 'deleted-profile', thinkingLevel: 'off', systemPrompt: 'persisted prompt', diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts similarity index 96% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts index f7240672b..eeb74d739 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts @@ -10,9 +10,9 @@ import { describe, expect, it } from 'vitest'; -import { AgentFileParseError, parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; -import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; -import type { AgentFileDefinition } from '#/app/agentFileCatalog/types'; +import { AgentFileParseError, parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; +import type { AgentFileDefinition } from '#/workspace/workspaceAgentProfileLoader/internal/types'; const FULL_FILE = `--- name: code-reviewer diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts similarity index 97% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts index 66657f548..b89173fcf 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts @@ -13,8 +13,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import type { AgentFileRoot } from '#/workspace/workspaceAgentProfileLoader/internal/types'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts new file mode 100644 index 000000000..f6cf5f332 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts @@ -0,0 +1,828 @@ +/** + * Scenario: agent-profile loaders + session catalog — the Contribution / + * Registry / Catalog extension point end to end. Exercises the real loader + * services (builtin / user / plugin / workspace / extra / explicit), the + * App-scope `AgentProfileRegistryService`, and the Session-scope + * `SessionAgentProfileCatalogService` as directly-constructed instances (no DI + * scope host) against real temp directories: source-priority merge, the + * builtin-override rule, explicit fatal semantics, config / plugin-reload / + * fs-watch driven reloads, and SYSTEM.md interplay. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts`. + */ + +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Emitter, Event } from '#/_base/event'; +import type { ILogService } from '#/_base/log/log'; +import type { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { EXTRA_AGENT_DIRS_SECTION } from '#/workspace/workspaceAgentProfileLoader/configSection'; +import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +import type { PluginAgentRoot } from '#/app/plugin/types'; +import { + DEFAULT_AGENT_PROFILE_NAME, + type AgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +import { + _clearAgentProfileContributionsForTests, + registerAgentProfile, +} from '#/app/agentProfileCatalog/contribution'; +import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; +import { + type HostFsChange, + type IHostFsWatchHandle, + type IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; +import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; +import type { ISessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { stubBootstrap } from '../../app/bootstrap/stubs'; + +function configStub(): IConfigService & { + setExtraAgentDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; +} { + let extraAgentDirs: readonly string[] = []; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => + domain === EXTRA_AGENT_DIRS_SECTION ? [...extraAgentDirs] : undefined, + inspect: () => ({ + value: undefined, + defaultValue: undefined, + userValue: undefined, + memoryValue: undefined, + }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraAgentDirs: (dirs: readonly string[]) => { + extraAgentDirs = [...dirs]; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraAgentDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; + }; +} + +function workspaceContextStub(workDir: string): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'wd_test', + cwd: workDir, + source: 'local', + meta: { id: 'wd_test', root: workDir, name: 'test', createdAt: 0, lastOpenedAt: 0 }, + persistenceScope: 'sessions/wd_test', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }; +} + +function agentMd(name: string, description: string, override = false): string { + const overrideLine = override ? 'override: true\n' : ''; + return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`; +} + +interface Fixture { + readonly homeDir: string; + readonly osHomeDir: string; + readonly workDir: string; + readonly extraDir: string; +} + +async function withFixture(run: (fixture: Fixture) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-profile-loader-')); + try { + const make = async (dir: string): Promise => { + const p = join(root, dir); + await mkdir(p, { recursive: true }); + return realpath(p); + }; + const [homeDir, osHomeDir, workDir, extraDir] = await Promise.all([ + make('kimi-home'), + make('os-home'), + make('work'), + make('extra-agents'), + ]); + await run({ homeDir, osHomeDir, workDir, extraDir }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeAgent(dir: string, fileName: string, content: string): Promise { + await mkdir(dir, { recursive: true }); + const filePath = join(dir, fileName); + await writeFile(filePath, content); + return filePath; +} + +function logStub(warnings?: string[]): ILogService { + return { + _serviceBrand: undefined, + warn: (message: unknown) => { + warnings?.push(String(message)); + }, + info: () => {}, + error: () => {}, + debug: () => {}, + trace: () => {}, + setLevel: () => {}, + } as unknown as ILogService; +} + +function pluginStub( + agentRoots: readonly PluginAgentRoot[] = [], + reloadEmitter?: Emitter, +): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => agentRoots, + enabledSessionStarts: async () => [], + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function waitForEvent(event: Event): Promise { + return new Promise((resolve) => { + const disposable = event(() => { + disposable.dispose(); + resolve(); + }); + }); +} + +/** + * Wraps a real `HostFileSystem` so `readdir` throws an injected + * `os.fs.unavailable` whenever `shouldFail` accepts the path — the failure + * class that propagates out of discovery and exercises the loader failure + * policy (non-fatal warn / fatal reject). + */ +function failingReaddirFs( + hostFs: HostFileSystem, + shouldFail: (path: string) => boolean, +): HostFileSystem { + const failing = Object.create(hostFs) as HostFileSystem; + failing.readdir = async (path: string) => { + if (shouldFail(path)) { + throw new HostFsError( + OsFsErrors.codes.OS_FS_UNAVAILABLE, + `injected readdir failure for ${path}`, + ); + } + return hostFs.readdir(path); + }; + return failing; +} + +interface StackOptions { + readonly extraAgentDirs?: readonly string[]; + readonly explicitFiles?: readonly string[]; + readonly pluginAgentRoots?: readonly PluginAgentRoot[]; + readonly pluginReloadEmitter?: Emitter; + readonly hostFs?: HostFileSystem; + readonly fsWatch?: IHostFsWatchService; +} + +/** + * Builds the whole loader stack as directly-constructed instances sharing one + * registry, plus the session catalog projected over it. Loaders start their + * first load in their constructors, so callers `await stack.ready()` (or the + * individual `loader.ready`) before asserting. + */ +function makeStack(fixture: Fixture, opts?: StackOptions) { + const warnings: string[] = []; + const log = logStub(warnings); + const config = configStub(); + if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs); + const bootstrap: IBootstrapService = { + ...stubBootstrap(fixture.homeDir), + osHomeDir: fixture.osHomeDir, + }; + const hostFs = opts?.hostFs ?? new HostFileSystem(); + const workspaceContext = workspaceContextStub(fixture.workDir); + const runtimeOptions = { + _serviceBrand: undefined, + explicitFiles: opts?.explicitFiles, + } as unknown as IAgentCatalogRuntimeOptions; + + const registry = new AgentProfileRegistryService(); + const builtinLoader = new BuiltinAgentProfileLoaderService(registry); + const userLoader = new UserAgentProfileLoaderService( + bootstrap, + hostFs, + log, + builtinLoader, + registry, + workspaceContext, + ); + const pluginLoader = new PluginAgentProfileLoaderService( + pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter), + hostFs, + log, + userLoader, + registry, + workspaceContext, + ); + const workspaceLoader = new WorkspaceAgentProfileLoaderService( + workspaceContext, + hostFs, + log, + userLoader, + opts?.fsWatch ?? fsWatchStub(), + registry, + ); + const extraLoader = new ExtraAgentProfileLoaderService( + config, + workspaceContext, + bootstrap, + hostFs, + log, + userLoader, + registry, + ); + const explicitLoader = new ExplicitAgentProfileLoaderService( + runtimeOptions, + workspaceContext, + bootstrap, + hostFs, + log, + userLoader, + registry, + ); + const seed: ISessionAgentProfileCatalogSeed = { + _serviceBrand: undefined, + workspaceKey: workspaceContext.workspaceId, + }; + const catalog = new SessionAgentProfileCatalogService(registry, seed, log); + + return { + registry, + builtinLoader, + userLoader, + pluginLoader, + workspaceLoader, + extraLoader, + explicitLoader, + catalog, + config, + warnings, + /** Awaits every loader's latest load pass plus the catalog readiness. */ + async ready(): Promise { + await Promise.all([ + userLoader.ready, + pluginLoader.ready, + workspaceLoader.ready, + extraLoader.ready, + explicitLoader.ready, + ]); + await catalog.ready; + }, + dispose(): void { + for (const disposable of [ + catalog, + explicitLoader, + extraLoader, + workspaceLoader, + pluginLoader, + userLoader, + builtinLoader, + registry, + ]) { + disposable.dispose(); + } + }, + }; +} + +type Stack = ReturnType; + +async function withStack( + fixture: Fixture, + opts: StackOptions | undefined, + run: (stack: Stack) => Promise, +): Promise { + const stack = makeStack(fixture, opts); + try { + await run(stack); + } finally { + stack.dispose(); + } +} + +describe('agent profile loaders + session catalog', () => { + beforeEach(() => { + // The builtin loader snapshots the module-level contributions on + // construction; pin them to one known default profile per test. + _clearAgentProfileContributionsForTests(); + const builtinDefault: AgentProfile = { + name: DEFAULT_AGENT_PROFILE_NAME, + description: 'builtin default', + systemPrompt: () => 'BUILTIN PROMPT', + }; + registerAgentProfile(builtinDefault); + }); + + it('lists builtin profiles when no agent directories exist', async () => { + await withFixture(async (fixture) => { + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); + expect(stack.catalog.getDefault().name).toBe(DEFAULT_AGENT_PROFILE_NAME); + expect(stack.catalog.list().length).toBeGreaterThan(0); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('builtin'); + }); + }); + }); + + it('merges user and workspace agents; workspace wins on name collision', async () => { + await withFixture(async (fixture) => { + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await writeAgent(join(fixture.homeDir, 'agents'), 'user-only.md', agentMd('user-only', 'user agent')); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'shared.md', + agentMd('shared', 'from project'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from project'); + expect(stack.catalog.get('user-only')?.description).toBe('user agent'); + const inspection = stack.catalog.inspect('shared'); + expect(inspection?.sourceId).toBe('workspace'); + expect(inspection?.suppressed).toContainEqual({ + sourceId: 'user', + priority: 10, + reason: 'priority', + }); + }); + }); + }); + + it('orders sources user < extra < workspace < explicit', async () => { + await withFixture(async (fixture) => { + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await writeAgent(join(fixture.homeDir, 'agents'), 'user-extra.md', agentMd('user-extra', 'from user')); + await writeAgent(fixture.extraDir, 'shared.md', agentMd('shared', 'from extra')); + await writeAgent(fixture.extraDir, 'user-extra.md', agentMd('user-extra', 'from extra')); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'shared.md', + agentMd('shared', 'from project'), + ); + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('shared', 'from explicit'), + ); + await withStack( + fixture, + { extraAgentDirs: [fixture.extraDir], explicitFiles: [explicitFile] }, + async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from explicit'); + expect(stack.catalog.get('user-extra')?.description).toBe('from extra'); + expect(stack.catalog.inspect('shared')?.sourceId).toBe('explicit'); + }, + ); + }); + }); + + it('merges plugin agents below user; user wins on name collision', async () => { + await withFixture(async (fixture) => { + const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); + await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin')); + await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin')); + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await withStack( + fixture, + { pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }] }, + async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from user'); + expect(stack.catalog.get('plugin-only')?.description).toBe('from plugin'); + }, + ); + }); + }); + + it('reloads the plugin loader when plugins reload', async () => { + await withFixture(async (fixture) => { + const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); + await mkdir(pluginAgentsDir, { recursive: true }); + const reloadEmitter = new Emitter(); + await withStack( + fixture, + { + pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], + pluginReloadEmitter: reloadEmitter, + }, + async (stack) => { + await stack.ready(); + expect(stack.catalog.get('late')).toBeUndefined(); + + await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent')); + const changed = waitForEvent(stack.catalog.onDidChange); + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + await changed; + + expect(stack.catalog.get('late')?.description).toBe('late plugin agent'); + }, + ); + }); + }); + + it('fails ready when an explicit agent file is invalid', async () => { + await withFixture(async (fixture) => { + const bad = await writeAgent( + fixture.workDir, + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await withStack(fixture, { explicitFiles: [bad] }, async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toThrow(/description/i); + }); + }); + }); + + it('fails ready when an explicit agent file does not exist', async () => { + await withFixture(async (fixture) => { + await withStack( + fixture, + { explicitFiles: [join(fixture.workDir, 'missing.md')] }, + async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toMatchObject({ + code: 'os.fs.not_found', + }); + }, + ); + }); + }); + + it('recovers ready after a reload fixes a previously fatal explicit file', async () => { + await withFixture(async (fixture) => { + const bad = await writeAgent( + fixture.workDir, + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await withStack(fixture, { explicitFiles: [bad] }, async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toThrow(/description/i); + + await writeFile(bad, agentMd('fixed', 'fixed agent')); + await stack.explicitLoader.reload(); + + await expect(stack.explicitLoader.ready).resolves.toBeUndefined(); + expect(stack.catalog.get('fixed')?.description).toBe('fixed agent'); + }); + }); + }); + + it('resolves relative explicit files against the workspace root', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, 'agents'), + 'solo.md', + agentMd('solo', 'relative explicit'), + ); + await withStack(fixture, { explicitFiles: ['agents/solo.md'] }, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('solo')?.description).toBe('relative explicit'); + }); + }); + }); + + it('reloads the extra loader when extraAgentDirs changes', async () => { + await withFixture(async (fixture) => { + await writeAgent(fixture.extraDir, 'from-extra.md', agentMd('from-extra', 'extra agent')); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('from-extra')).toBeUndefined(); + + stack.config.setExtraAgentDirs([fixture.extraDir]); + const changed = waitForEvent(stack.catalog.onDidChange); + stack.config.fireSectionChange(EXTRA_AGENT_DIRS_SECTION); + await changed; + + expect(stack.catalog.get('from-extra')?.description).toBe('extra agent'); + }); + }); + }); + + it('skips invalid workspace files and still loads valid ones', async () => { + await withFixture(async (fixture) => { + const badPath = await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await writeAgent(join(fixture.workDir, '.kimi-code', 'agents'), 'good.md', agentMd('good', 'valid')); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('good')?.description).toBe('valid'); + expect(stack.catalog.get('bad')).toBeUndefined(); + const workspaceEntry = stack.registry.entries().find((e) => e.sourceId === 'workspace'); + expect(workspaceEntry?.contribution.skipped).toHaveLength(1); + expect(workspaceEntry?.contribution.skipped?.[0]?.path).toBe(badPath); + }); + }); + }); + + it('keeps the builtin default when a same-name file does not opt in to override', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('builtin default'); + expect(stack.catalog.getDefault().description).not.toBe('project default override'); + const inspection = stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME); + expect(inspection?.sourceId).toBe('builtin'); + expect(inspection?.suppressed).toContainEqual({ + sourceId: 'workspace', + priority: 30, + reason: 'builtin-override-required', + }); + }); + }); + }); + + it('lets a file profile explicitly override the builtin default', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override', true), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('project default override'); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('workspace'); + }); + }); + }); + + it('falls back to a valid lower-priority override when the higher candidate does not opt in', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'agent.md', + agentMd('agent', 'user default override', true), + ); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default without override'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('user default override'); + expect(stack.catalog.getDefault().description).not.toBe('project default without override'); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('user'); + }); + }); + }); + + it('keeps builtin profiles and warns when a non-fatal loader fails its first load', async () => { + await withFixture(async (fixture) => { + await mkdir(join(fixture.homeDir, 'agents'), { recursive: true }); + const hostFs = failingReaddirFs(new HostFileSystem(), () => true); + await withStack(fixture, { hostFs }, async (stack) => { + // A non-fatal first-load failure degrades to a warning; `ready` still + // resolves and nothing replaces the builtin contribution. + await stack.ready(); + + expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); + expect(stack.warnings.some((w) => w.includes('"user"') && w.includes('load failed'))).toBe( + true, + ); + expect(stack.registry.entries().some((e) => e.sourceId === 'user')).toBe(false); + }); + }); + }); + + it('keeps the previous contribution when a non-fatal loader reload fails', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'file-agent.md', + agentMd('file-agent', 'from file'), + ); + let fail = false; + const hostFs = failingReaddirFs(new HostFileSystem(), () => fail); + await withStack(fixture, { hostFs }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('file-agent')?.description).toBe('from file'); + + fail = true; + await stack.userLoader.reload(); + + expect(stack.warnings.some((w) => w.includes('load failed'))).toBe(true); + expect(stack.catalog.get('file-agent')?.description).toBe('from file'); + const userEntry = stack.registry.entries().find((e) => e.sourceId === 'user'); + expect(userEntry?.contribution.profiles.map((p) => p.name)).toContain('file-agent'); + }); + }); + }); + + it('warns and keeps stale data when a fatal loader reload fails', async () => { + await withFixture(async (fixture) => { + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('exp-agent', 'explicit'), + ); + await withStack(fixture, { explicitFiles: [explicitFile] }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('exp-agent')?.description).toBe('explicit'); + + await rm(explicitFile, { force: true }); + // Mirror the production event-handler call style + // (`void loader.reload().catch(warn)`): a rejecting fatal reload must + // surface as a warning, never crash, and leave the stale contribution. + void stack.explicitLoader + .reload() + .catch((error) => + stack.warnings.push(`agent profile loader "explicit" reload failed: ${String(error)}`), + ); + await vi.waitFor(() => { + expect(stack.warnings.some((w) => w.includes('reload failed'))).toBe(true); + }); + + expect(stack.catalog.get('exp-agent')?.description).toBe('explicit'); + const explicitEntry = stack.registry.entries().find((e) => e.sourceId === 'explicit'); + expect(explicitEntry?.contribution.profiles.map((p) => p.name)).toContain('exp-agent'); + }); + }); + }); + + it('replaces the builtin default system prompt with user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile( + join(fixture.homeDir, 'SYSTEM.md'), + 'You are a custom main agent. cwd=${cwd} unknown=${nope}', + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + const prompt = stack.catalog.getDefault().systemPrompt({ cwd: '/work/dir' }); + expect(prompt).toContain('You are a custom main agent.'); + expect(prompt).toContain('cwd=/work/dir'); + expect(prompt).toContain('unknown=${nope}'); + }); + }); + }); + + it('lets SYSTEM.md win over a same-name scanned user agent file', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'agent.md', + agentMd('agent', 'user agents dir default', true), + ); + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + const defaultProfile = stack.catalog.getDefault(); + expect(defaultProfile.systemPrompt({})).toContain('system md prompt'); + expect(defaultProfile.systemPrompt({})).not.toContain('You are agent.'); + }); + }); + }); + + it('lets a same-name workspace agent file win over user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override', true), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('project default override'); + expect(stack.catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); + }); + }); + }); + + it('lets an explicit agent file win over user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('agent', 'explicit default override', true), + ); + await withStack(fixture, { explicitFiles: [explicitFile] }, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('explicit default override'); + expect(stack.catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); + }); + }); + }); + + it('rescans the workspace source when a project agent file changes on disk', async () => { + await withFixture(async (fixture) => { + // Pre-create the candidate directory so the chokidar initial scan sees + // it; a file written afterwards is guaranteed a non-initial `add` event. + await mkdir(join(fixture.workDir, '.kimi-code', 'agents'), { recursive: true }); + await withStack(fixture, { fsWatch: new HostFsWatchService() }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('watched-agent')).toBeUndefined(); + + const refreshed = new Promise((resolvePromise) => { + const d = stack.catalog.onDidChange((sourceId) => { + if (sourceId !== 'workspace') return; + d.dispose(); + resolvePromise(sourceId); + }); + }); + const timedOut = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('watch-driven refresh timed out')), 10000); + }); + // Let the watcher finish its initial scan before the change lands. + await new Promise((resolve) => setTimeout(resolve, 300)); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'watched-agent.md', + agentMd('watched-agent', 'from watch'), + ); + + await expect(Promise.race([refreshed, timedOut])).resolves.toBe('workspace'); + expect(stack.catalog.get('watched-agent')?.description).toBe('from watch'); + }); + }); + }, 15000); +}); diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts similarity index 99% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts index 3530d83ce..857201f19 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts @@ -16,7 +16,7 @@ import { configuredAgentRoots, projectAgentRoots, userAgentRoots, -} from '#/app/agentFileCatalog/agentRoots'; +} from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts similarity index 98% rename from packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts index 4a23d49d7..4a3c91c01 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts @@ -23,7 +23,7 @@ import { import { SYSTEM_MD_FILENAME, loadSystemMdProfile, -} from '#/app/agentFileCatalog/systemFile'; +} from '#/workspace/workspaceAgentProfileLoader/internal/systemFile'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts new file mode 100644 index 000000000..89df2cadb --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -0,0 +1,486 @@ +/** + * Scenario: workspace-level add-dir (the phase-3.5 behavior contract). + * + * Drives the REAL handler chain (WorkspaceLifecycleService → + * WorkspaceHandlerService) with the real `WorkspaceDirsService`, the real + * node-fs `FileProjectLocalConfigService`, the real fs watch service, and + * the real Session-scope `workspaceContext` view, and proves: + * - a persisted `addDir` writes `.kimi-code/local.toml` and refreshes every + * live session's view of the workspace; + * - a second session of the same workspace sees the dir immediately; + * - a handler rebuilt from scratch (simulated restart) keeps the persisted + * dir; + * - `persist: false` stays in handler memory and never touches the disk; + * - an external `local.toml` edit (another process) refreshes live session + * views through the fs watch. + * Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceDirs/workspaceDirs.test.ts`. + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, + type ISessionScopeHandle, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { + IProjectLocalConfigService, +} from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; + +import { stubLog } from '../../_base/log/stubs'; + +function workspaceCatalogStub(): IWorkspaceService { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const workspace: Workspace = workspaces.get(id) ?? { + id, + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + +function workspaceSkillCatalogStub(): IWorkspaceSkillCatalog { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + }), + } as unknown as IWorkspaceSkillCatalog; +} + +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + reload: () => Promise.resolve(), + }; +} + +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { + throw new Error('not implemented'); + }, + }; +} + +function workspaceInstructionsStub(): IWorkspaceInstructionsService { + const provider = { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None, + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange: Event.None, + reload: () => Promise.resolve(), + sessionProvider: () => provider, + } as unknown as IWorkspaceInstructionsService; +} + +function workspaceMcpStub(): IWorkspaceMcpService { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + sessionHandle: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + get connectionManager(): never { + throw new Error('not implemented'); + }, + }), + } as unknown as IWorkspaceMcpService; +} + +describe('workspace add-dir (handler chain)', () => { + let hosts: ScopedTestHost[]; + let tmpRoots: string[]; + + beforeEach(() => { + _clearScopedRegistryForTests(); + hosts = []; + tmpRoots = []; + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Session, + ISessionWorkspaceContext, + SessionWorkspaceContextService, + ScopeActivation.OnScopeCreated, + 'workspaceContext', + ); + }); + + afterEach(async () => { + for (const host of hosts.splice(0)) { + host.dispose(); + } + await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tmpRoots.push(root); + return root; + } + + /** A project root with a `.git` marker so local.toml lands at the root. */ + async function makeProjectRoot(): Promise { + const root = await makeRoot('kimi-add-dir-proj-'); + await mkdir(join(root, '.git')); + return root; + } + + function buildHost(homeDir: string): ScopedTestHost { + const bootstrap = { + _serviceBrand: undefined, + homeDir, + osHomeDir: homeDir, + scope: (name: string) => name, + } as unknown as IBootstrapService; + const hostFs = new HostFileSystem(); + const host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrap), + stubPair(IHostEnvironment, { + _serviceBrand: undefined, + osKind: 'Linux', + homeDir, + ready: Promise.resolve(), + } as unknown as IHostEnvironment), + stubPair(IHostFileSystem, hostFs), + stubPair(IHostFsWatchService, new HostFsWatchService()), + stubPair(ILogService, stubLog()), + stubPair(IConfigService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(ITelemetryService, noopTelemetryService), + stubPair(IWorkspaceService, workspaceCatalogStub()), + stubPair(ISessionIndex, { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + } as unknown as ISessionIndex), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => Event.None, + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } as unknown as IEventService), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + } as unknown as ICronTaskPersistence), + stubPair( + IProjectLocalConfigService, + new FileProjectLocalConfigService(bootstrap, hostFs), + ), + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } as unknown as ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } as unknown as ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IAgentLifecycleService, { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService), + stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()), + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()), + stubPair(IWorkspaceMcpService, workspaceMcpStub()), + ]); + hosts.push(host); + return host; + } + + async function handlerFor( + host: ScopedTestHost, + root: string, + ): Promise<{ service: IWorkspaceHandlerService; dirs: IWorkspaceDirs }> { + const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); + return { + service: handler.accessor.get(IWorkspaceHandlerService), + dirs: handler.accessor.get(IWorkspaceDirs), + }; + } + + function dirsOf(handle: ISessionScopeHandle): readonly string[] { + return handle.accessor.get(ISessionWorkspaceContext).additionalDirs; + } + + it('persists addDir to local.toml and every session of the workspace sees the dir', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + const result = await dirs.addDir({ path: extra, persist: true }); + + expect(result.persisted).toBe(true); + expect(result.projectRoot).toBe(root); + expect(result.configPath).toBe(join(root, '.kimi-code', 'local.toml')); + expect(result.additionalDirs).toEqual([extra]); + // local.toml written on disk. + const toml = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf8'); + expect(toml).toContain('additional_dir'); + expect(toml).toContain(extra); + // The live session's view refreshed through the change event. + expect(dirsOf(s1)).toEqual([extra]); + // A second session of the same workspace sees it immediately. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('keeps the persisted dir across a handler rebuild (simulated restart)', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const first = buildHost(homeDir); + const firstHandler = await handlerFor(first, root); + await firstHandler.service.create({ sessionId: 's1', workDir: root }); + await firstHandler.dirs.addDir({ path: extra, persist: true }); + first.dispose(); + hosts = hosts.filter((h) => h !== first); + + const second = buildHost(homeDir); + const secondHandler = await handlerFor(second, root); + const s2 = await secondHandler.service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('persist: false stays in handler memory, never touches the disk, and is shared', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + + const result = await dirs.addDir({ path: extra, persist: false }); + + expect(result.persisted).toBe(false); + expect(result.additionalDirs).toEqual([extra]); + expect(dirsOf(s1)).toEqual([extra]); + // Nothing written: local.toml does not exist. + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + // The in-memory dir is shared with a second session of the workspace. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('refreshes live session views when another process edits local.toml', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + // External write (another process, an editor, `kimi` in a second CLI). + await mkdir(join(root, '.kimi-code'), { recursive: true }); + const writeLocalToml = () => + writeFile(join(root, '.kimi-code', 'local.toml'), `[workspace]\nadditional_dir = ["${extra}"]\n`); + await writeLocalToml(); + + // The chokidar watcher ignores files it finds during its initial scan + // (`ignoreInitial`), so a write landing inside that window is swallowed; + // rewrite while polling (slower than the 200ms reload debounce, so the + // debounce always gets a quiet window) until a `modify` event lands. + const deadline = Date.now() + 10_000; + while (!dirsOf(s1).includes(extra)) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for watch-driven local.toml reload'); + } + await writeLocalToml(); + await new Promise((resolve) => setTimeout(resolve, 400)); + } + }, 15_000); + + it('unions caller additionalDirs from create options into the shared set', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root, additionalDirs: [extra] }); + expect(dirsOf(s1)).toEqual([extra]); + // Caller dirs join the handler-shared set: a session created WITHOUT the + // option sees them too, and nothing was persisted. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts similarity index 96% rename from packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts index 7d86ceaaf..0eb576095 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { type IProcess, type ISessionProcessRunner } from '#/session/process/processRunner'; -import { runCommand, type RunCommandOptions } from '#/session/sessionFs/fsProcess'; +import { runCommand, type RunCommandOptions } from '#/workspace/workspaceFs/fsProcess'; interface FakeProcessOptions { readonly stdout?: string; diff --git a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts similarity index 98% rename from packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts index 3f7da0b21..6c171e240 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts @@ -7,7 +7,7 @@ import { matchesAnyGlob, rgPath, stripTrailingNewline, -} from '#/session/sessionFs/fsSearch'; +} from '#/workspace/workspaceFs/fsSearch'; describe('computeFuzzyScore', () => { it('returns 0 for an empty query', () => { diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts similarity index 86% rename from packages/agent-core-v2/test/session/sessionFs/fsService.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index d53a3bc47..a9b73c976 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -13,31 +13,48 @@ import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { IGitService } from '#/app/git/git'; import { ErrorCodes, Error2 } from '#/errors'; import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionFsService } from '#/session/sessionFs/fs'; -import { SessionFsService } from '#/session/sessionFs/fsService'; +import { IWorkspaceFsService } from '#/workspace/workspaceFs/fs'; +import { WorkspaceFsService } from '#/workspace/workspaceFs/fsService'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; import { ITelemetryService, type TelemetryProperties } from '#/app/telemetry/telemetry'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit'; const WORK_DIR = '/repo'; -function stubWorkspace(): ISessionWorkspaceContext { +function stubWorkspaceContext(): IWorkspaceContext { return { _serviceBrand: undefined, - workDir: WORK_DIR, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function stubWorkspaceDirs(): IWorkspaceDirs { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)), - isWithin: (abs) => { - const r = relative(WORK_DIR, abs); - return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + onDidChange: () => ({ dispose: () => {} }), + addDir: () => Promise.reject(new Error('not supported in tests')), + mergeAdditionalDirs: () => Promise.resolve(), + sessionInfo: () => { + throw new Error('not supported in tests'); }, - assertAllowed: (abs) => abs, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, + }; +} + +function workspaceGitStub(git: IGitService): IWorkspaceGitService { + return { + _serviceBrand: undefined, + status: (filter) => git.status(WORK_DIR, filter), + diff: (rel, abs) => git.diff(WORK_DIR, rel, abs), }; } @@ -299,18 +316,11 @@ function telemetryStub(events: Array<{ event: string; properties: Record { _clearScopedRegistryForTests(); registerScopedService( - LifecycleScope.Session, - ISessionStateService, - SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', - ); - registerScopedService( - LifecycleScope.Session, - ISessionFsService, - SessionFsService, + LifecycleScope.Workspace, + IWorkspaceFsService, + WorkspaceFsService, ScopeActivation.OnDemand, - 'sessionFs', + 'workspaceFs', ); }); @@ -334,6 +344,7 @@ function defaultGitStub(): IGitService { pullRequest: null, }), diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; } @@ -345,22 +356,23 @@ function makeSession( symlinks: readonly string[] = [], runner?: ISessionProcessRunner, symlinkTargets: Record = {}, -): ISessionFsService { +): IWorkspaceFsService { host = createScopedTestHost(); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, stubWorkspace()), + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext()), + stubPair(IWorkspaceDirs, stubWorkspaceDirs()), stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)), stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)), stubPair(ITelemetryService, telemetryStub(events)), - stubPair(IGitService, git), + stubPair(IWorkspaceGitService, workspaceGitStub(git)), ]); - return session.accessor.get(ISessionFsService); + return workspace.accessor.get(IWorkspaceFsService); } const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 }); -describe('SessionFsService.gitStatus', () => { - it('delegates to IGitService with the session cwd and a confined filter', async () => { +describe('WorkspaceFsService.gitStatus', () => { + it('delegates to IWorkspaceGitService with the handler root and a confined filter', async () => { const calls: Array<{ cwd: string; filter: ReadonlySet | undefined }> = []; const git: IGitService = { _serviceBrand: undefined, @@ -377,6 +389,7 @@ describe('SessionFsService.gitStatus', () => { }; }, diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); const result = await fs.gitStatus({ paths: ['src/a.ts'] }); @@ -395,14 +408,15 @@ describe('SessionFsService.gitStatus', () => { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'git unavailable at /repo: not a repo'); }, diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); await expect(fs.gitStatus({})).rejects.toMatchObject({ code: 'fs.git_unavailable' }); }); }); -describe('SessionFsService.diff', () => { - it('delegates to IGitService with confined rel and abs paths', async () => { +describe('WorkspaceFsService.diff', () => { + it('delegates to IWorkspaceGitService with confined rel and abs paths', async () => { const calls: Array<{ cwd: string; rel: string; abs: string }> = []; const git: IGitService = { _serviceBrand: undefined, @@ -419,6 +433,7 @@ describe('SessionFsService.diff', () => { calls.push({ cwd, rel, abs }); return { path: rel, diff: '-old\n+new\n', truncated: false }; }, + findWorkTree: async () => null, }; const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git); const result = await fs.diff({ path: 'src/a.ts' }); @@ -438,7 +453,7 @@ describe('SessionFsService.diff', () => { }); }); -describe('SessionFsService.search', () => { +describe('WorkspaceFsService.search', () => { it('finds files by fuzzy query and respects the result cap', async () => { const fs = makeSession( { 'src/foo.ts': '', 'src/bar.ts': '', 'README.md': '' }, @@ -464,9 +479,43 @@ describe('SessionFsService.search', () => { expect(result.items.find((i) => i.path === 'src/link')?.kind).toBe('symlink'); expect(paths.some((p) => p.startsWith('src/link/'))).toBe(false); }); + + it('lists the workspace root top-level entries when the query is empty', async () => { + const fs = makeSession( + { 'src/foo.ts': '', 'src/nested/deep.ts': '', 'README.md': '', '.hidden.ts': '' }, + emptyHandler, + ); + const result = await fs.search({ query: '', limit: 50, follow_gitignore: false }); + // Dirs first, then files, alphabetical inside each group; hidden entries + // and nested paths are not listed. + expect(result.items.map((i) => i.path)).toEqual(['src', 'README.md']); + expect(result.items[0]).toMatchObject({ + name: 'src', + kind: 'directory', + score: 1, + match_positions: [], + }); + expect(result.truncated).toBe(false); + }); + + it('truncates the empty-query listing at the limit', async () => { + const fs = makeSession({ 'a.ts': '', 'b.ts': '', 'c.ts': '' }, emptyHandler); + const result = await fs.search({ query: '', limit: 2, follow_gitignore: false }); + expect(result.items.map((i) => i.path)).toEqual(['a.ts', 'b.ts']); + expect(result.truncated).toBe(true); + }); + + it('respects .gitignore in the empty-query listing', async () => { + const fs = makeSession( + { '.gitignore': 'ignored.ts\n', 'ignored.ts': '', 'kept.ts': '' }, + emptyHandler, + ); + const result = await fs.search({ query: '', limit: 50, follow_gitignore: true }); + expect(result.items.map((i) => i.path)).toEqual(['kept.ts']); + }); }); -describe('SessionFsService.grep', () => { +describe('WorkspaceFsService.grep', () => { it('falls back to the node implementation when rg is unavailable', async () => { const events: Array<{ event: string; properties: Record }> = []; const fs = makeSession( @@ -581,7 +630,7 @@ describe('SessionFsService.grep', () => { }); }); -describe('SessionFsService.list', () => { +describe('WorkspaceFsService.list', () => { it('lists files and directories with kinds', async () => { const fs = makeSession( { 'src/a.ts': '', 'src/sub/b.ts': '', 'README.md': '' }, @@ -634,7 +683,7 @@ describe('SessionFsService.list', () => { }); }); -describe('SessionFsService.read', () => { +describe('WorkspaceFsService.read', () => { it('reads utf-8 content with metadata', async () => { const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler); const result = await fs.read({ @@ -682,7 +731,7 @@ describe('SessionFsService.read', () => { }); }); -describe('SessionFsService.stat', () => { +describe('WorkspaceFsService.stat', () => { it('returns a file entry with mime', async () => { const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler); const entry = await fs.stat({ path: 'src/a.ts' }); @@ -698,7 +747,7 @@ describe('SessionFsService.stat', () => { }); }); -describe('SessionFsService.statMany', () => { +describe('WorkspaceFsService.statMany', () => { it('returns null per missing path and entries for present ones', async () => { const fs = makeSession({ 'a.txt': 'hi' }, emptyHandler); const result = await fs.statMany({ paths: ['a.txt', 'missing.txt'] }); @@ -707,7 +756,7 @@ describe('SessionFsService.statMany', () => { }); }); -describe('SessionFsService.listMany', () => { +describe('WorkspaceFsService.listMany', () => { it('returns results per path and partial_errors for failures', async () => { const fs = makeSession({ 'a.txt': '' }, emptyHandler); const result = await fs.listMany({ @@ -724,7 +773,7 @@ describe('SessionFsService.listMany', () => { }); }); -describe('SessionFsService.mkdir', () => { +describe('WorkspaceFsService.mkdir', () => { it('creates a directory and returns its entry', async () => { const fs = makeSession({}, emptyHandler); const entry = await fs.mkdir({ path: 'newdir', recursive: false }); @@ -740,7 +789,7 @@ describe('SessionFsService.mkdir', () => { }); }); -describe('SessionFsService.resolvePath', () => { +describe('WorkspaceFsService.resolvePath', () => { it('returns absolute, relative, and isDirectory', async () => { const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); const res = await fs.resolvePath('src/a.ts'); @@ -750,7 +799,7 @@ describe('SessionFsService.resolvePath', () => { }); }); -describe('SessionFsService.resolveDownload', () => { +describe('WorkspaceFsService.resolveDownload', () => { it('returns size, etag, mime, modifiedAt', async () => { const fs = makeSession({ 'a.txt': 'hello' }, emptyHandler); const res = await fs.resolveDownload('a.txt'); @@ -766,10 +815,10 @@ describe('SessionFsService.resolveDownload', () => { }); }); -describe('SessionFsService symlink confinement', () => { +describe('WorkspaceFsService symlink confinement', () => { const escapeTargets = { docs: '/outside' }; - function escapeSession(): ISessionFsService { + function escapeSession(): IWorkspaceFsService { return makeSession( { 'src/a.ts': '' }, emptyHandler, diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts new file mode 100644 index 000000000..ef5a39498 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts @@ -0,0 +1,298 @@ +/** + * `workspaceFs` fs-watch (L3) — verifies the shared os watcher fan-out: + * confinement to each subscription's declared subtree, workspace-relative + * path mapping, per-subscription debounce coalescing and window truncation, + * `.gitignore` filtering, and the handle lifecycle (one os watch per handler + * no matter how many subscriptions), using a fake os watcher. + */ + +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LifecycleScope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + type HostFsChange, + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import type { FsChangeEvent } from '#/workspace/workspaceFs/fsWatch'; + +import { IWorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatch'; +import { WorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatchService'; + +const WORK_DIR = '/repo'; + +void WorkspaceFsWatchService; + +function stubWorkspaceContext(): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function stubWorkspaceDirs(): IWorkspaceDirs { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: [], + onDidChange: () => ({ dispose: () => {} }), + addDir: () => Promise.reject(new Error('not supported in tests')), + mergeAdditionalDirs: () => Promise.resolve(), + sessionInfo: () => { + throw new Error('not supported in tests'); + }, + }; +} + +interface FakeWatch { + readonly service: IHostFsWatchService; + readonly watchCalls: string[]; + fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; + readonly disposedCount: () => number; +} + +function fakeHostFsWatch(): FakeWatch { + const watchCalls: string[] = []; + let listener: ((e: HostFsChange) => void) | undefined; + let disposedCount = 0; + const handle: IHostFsWatchHandle = { + onDidChange: (l) => { + listener = l; + return { dispose: () => (listener = undefined) }; + }, + dispose: () => { + disposedCount += 1; + listener = undefined; + }, + }; + const service: IHostFsWatchService = { + _serviceBrand: undefined, + watch: (path) => { + watchCalls.push(path); + return handle; + }, + }; + return { + service, + watchCalls, + fire: (rel, action, kind = 'file') => + listener?.({ path: join(WORK_DIR, rel), action, kind }), + disposedCount: () => disposedCount, + }; +} + +function fakeHostFs(gitignore?: string): IHostFileSystem { + return { + _serviceBrand: undefined, + readText: async (p: string) => { + if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }, + } as unknown as IHostFileSystem; +} + +interface Harness { + readonly svc: IWorkspaceFsWatchService; + readonly watch: FakeWatch; +} + +function makeWorkspace(gitignore?: string): Harness { + const watch = fakeHostFsWatch(); + const host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext()), + stubPair(IWorkspaceDirs, stubWorkspaceDirs()), + stubPair(IHostFsWatchService, watch.service), + stubPair(IHostFileSystem, fakeHostFs(gitignore)), + ]); + const svc = workspace.accessor.get(IWorkspaceFsWatchService); + disposers.push(() => host.dispose()); + return { svc, watch }; +} + +function collect(sub: { onDidChangeFiles: (l: (e: FsChangeEvent) => void) => unknown }): FsChangeEvent[] { + const events: FsChangeEvent[] = []; + sub.onDidChangeFiles((e) => events.push(e)); + return events; +} + +const disposers: Array<() => void> = []; + +describe('WorkspaceFsWatchService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + for (const d of disposers.splice(0)) d(); + vi.useRealTimers(); + }); + + it('starts the os watcher on the workspace root for a non-empty subscription', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + expect(watch.watchCalls).toEqual([WORK_DIR]); + expect(sub.watchedPaths).toEqual(['src']); + }); + + it('drops events outside the subscribed subtree', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + const events = collect(sub); + + watch.fire('src/a.ts', 'created'); + watch.fire('lib/b.ts', 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); + }); + + it('coalesces changes within a window into one event', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + + watch.fire('a.ts', 'created'); + watch.fire('b.ts', 'modified'); + watch.fire('c.ts', 'deleted'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.coalesced_window_ms).toBe(200); + expect(events[0]?.changes).toHaveLength(3); + }); + + it('marks the event truncated when the window overflows', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + + for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.truncated).toBe(true); + expect(events[0]?.changes).toEqual([]); + expect(events[0]?.count).toBe(501); + }); + + it('filters out `.gitignore`d paths once loaded', async () => { + const { svc, watch } = makeWorkspace('dist/\n'); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + await Promise.resolve(); + await Promise.resolve(); + + watch.fire('dist/x.js', 'created'); + watch.fire('src/keep.ts', 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); + }); + + it('rejects paths that escape the workspace', () => { + const { svc } = makeWorkspace(); + const sub = svc.subscribe(); + expect(() => sub.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/); + expect(() => sub.setWatchedPaths(['/abs'])).toThrowError(/rejected/); + }); + + it('disposes the os handle when the last watched path set becomes empty', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + expect(watch.disposedCount()).toBe(0); + sub.setWatchedPaths([]); + expect(watch.disposedCount()).toBe(1); + }); + + it('does not fire after the service is disposed', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + watch.fire('a.ts', 'created'); + (svc as unknown as { dispose: () => void }).dispose(); + vi.advanceTimersByTime(200); + expect(events).toHaveLength(0); + }); + + // Phase-4 behavior contract: two sessions of one workspace share the + // handler's single os watch — subscriptions fan out, they never hang a + // second watcher. + it('shares one os watch across subscriptions and fans events out per subscription', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['src']); + subB.setWatchedPaths(['lib']); + subB.setWatchedPaths(['lib', 'src']); + const eventsA = collect(subA); + const eventsB = collect(subB); + + expect(watch.watchCalls).toEqual([WORK_DIR]); + + watch.fire('src/a.ts', 'created'); + watch.fire('lib/b.ts', 'modified'); + watch.fire('other/c.ts', 'deleted'); + vi.advanceTimersByTime(200); + + expect(eventsA).toHaveLength(1); + expect(eventsA[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); + expect(eventsB).toHaveLength(1); + expect(eventsB[0]?.changes).toHaveLength(2); + }); + + it('keeps the shared os watch alive while any subscription still watches', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['src']); + subB.setWatchedPaths(['lib']); + expect(watch.watchCalls).toEqual([WORK_DIR]); + + subA.setWatchedPaths([]); + expect(watch.disposedCount()).toBe(0); + + subB.dispose(); + expect(watch.disposedCount()).toBe(1); + }); + + it('gives each subscription its own truncation counters', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['.']); + subB.setWatchedPaths(['.']); + const eventsA = collect(subA); + const eventsB = collect(subB); + + for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); + vi.advanceTimersByTime(200); + + expect(eventsA).toHaveLength(1); + expect(eventsB).toHaveLength(1); + expect(eventsA[0]?.truncated).toBe(true); + expect(eventsB[0]?.truncated).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts similarity index 81% rename from packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts rename to packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index 40766015e..9beaa25c8 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -14,30 +14,51 @@ import { } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import type { Hooks } from '#/hooks'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { IEventService } from '#/app/event/event'; import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IAgentPlanService } from '#/agent/plan/plan'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; -import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; +import { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -52,31 +73,20 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; function bootstrapStub(): IBootstrapService { return { - sessionsDir: '/tmp/sessions', homeDir: '/tmp', - sessionScope: (workspaceId: string, sessionId: string) => - `sessions/${workspaceId}/${sessionId}`, - agentScope: (workspaceId: string, sessionId: string, agentId: string) => - `sessions/${workspaceId}/${sessionId}/agents/${agentId}`, - sessionDir: (workspaceId: string, sessionId: string) => - `/tmp/sessions/${workspaceId}/${sessionId}`, - } as IBootstrapService; + scope: (name: string) => name, + } as unknown as IBootstrapService; } function tmpBootstrapStub(root: string): IBootstrapService { return { - sessionsDir: join(root, 'sessions'), homeDir: root, - sessionScope: (workspaceId: string, sessionId: string) => - `sessions/${workspaceId}/${sessionId}`, - agentScope: (workspaceId: string, sessionId: string, agentId: string) => - `sessions/${workspaceId}/${sessionId}/agents/${agentId}`, - sessionDir: (workspaceId: string, sessionId: string) => - join(root, 'sessions', workspaceId, sessionId), - } as IBootstrapService; + scope: (name: string) => name, + } as unknown as IBootstrapService; } function cronStoreStub( @@ -135,39 +145,75 @@ function hostEnvironmentStub(): IHostEnvironment { }; } -function skillCatalogStub(): ISessionSkillCatalog { +function workspaceSkillCatalogStub(): IWorkspaceSkillCatalog { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; return { _serviceBrand: undefined, - catalog: { - getSkill: () => undefined, - getPluginSkill: () => undefined, - renderSkillPrompt: () => '', - listSkills: () => [], - listInvocableSkills: () => [], - getSkillRoots: () => [], - getSkippedByPolicy: () => [], - getModelSkillListing: () => '', - }, ready: Promise.resolve(), + catalog, onDidChange: () => ({ dispose: () => {} }), - load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: () => ({ dispose: () => {} }), + }), + } as unknown as IWorkspaceSkillCatalog; +} + +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), reload: () => Promise.resolve(), }; } -function agentProfileCatalogStub(): ISessionAgentProfileCatalog { +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { + throw new Error('not implemented'); + }, + }; +} + +function agentProfileLoaderStubs(): ReturnType[] { + return [ + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + ]; +} + +function workspaceInstructionsStub(): IWorkspaceInstructionsService { + const provider = { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: () => ({ dispose: () => {} }), + }; return { _serviceBrand: undefined, ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, onDidChange: () => ({ dispose: () => {} }), - get: () => undefined, - getDefault: () => { - throw new Error('not implemented'); - }, - list: () => [], - load: () => Promise.resolve(), reload: () => Promise.resolve(), - }; + sessionProvider: () => provider, + } as unknown as IWorkspaceInstructionsService; } function workspaceStub(): IWorkspaceService { @@ -311,15 +357,20 @@ function agentLifecycleStub(): IAgentLifecycleService { }; } -function sessionMcpServiceStub( - ensureMcpReady: () => Promise = () => Promise.resolve(), -): ISessionMcpService { +function workspaceMcpServiceStub(ready: Promise = Promise.resolve()): IWorkspaceMcpService { return { _serviceBrand: undefined, - ensureMcpReady, + ready, connectionManager: () => { throw new Error('not implemented'); }, + sessionHandle: () => ({ + _serviceBrand: undefined, + ready, + get connectionManager(): McpConnectionManager { + throw new Error('not implemented'); + }, + }), }; } @@ -402,24 +453,27 @@ class RecordingSessionExternalHooksService { declare readonly _serviceBrand: undefined; - constructor(@ISessionLifecycleService lifecycle: ISessionLifecycleService) { + constructor( + @ISessionContext private readonly context: ISessionContext, + @ISessionLifecycleHooks hooks: Hooks, + ) { super(); this._register( - lifecycle.hooks.onDidCreateSession.register('test', async (event, next) => { - recordedSessionHookEvents.push(`create:${event.source}:${event.sessionId}`); + hooks.onDidCreateSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`create:${event.source}:${this.context.sessionId}`); await next(); }), ); this._register( - lifecycle.hooks.onWillCloseSession.register('test', async (event, next) => { - recordedSessionHookEvents.push(`close:${event.reason}:${event.sessionId}`); + hooks.onWillCloseSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`close:${event.reason}:${this.context.sessionId}`); await next(); }), ); } } -describe('SessionLifecycleService', () => { +describe('WorkspaceHandlerService', () => { let host: ScopedTestHost | undefined; let telemetryRecords: TelemetryRecord[]; let tmpRoots: string[]; @@ -431,10 +485,31 @@ describe('SessionLifecycleService', () => { _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.App, - ISessionLifecycleService, - SessionLifecycleService, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, ScopeActivation.OnDemand, - 'sessionLifecycle', + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', ); registerScopedService( LifecycleScope.Session, @@ -458,21 +533,33 @@ describe('SessionLifecycleService', () => { await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); }); - function build(extra: ReturnType[] = []): ISessionLifecycleService { + /** + * Build the App host and materialize the default handler (`/tmp/proj`, + * `wd_stub` with the default workspace stub), returning its session + * lifecycle service. + */ + async function build( + extra: ReturnType[] = [], + ): Promise { host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub()), stubPair(ISessionMetadata, metadataStub()), stubPair(IHostEnvironment, hostEnvironmentStub()), - stubPair(ISessionSkillCatalog, skillCatalogStub()), + stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()), stubPair(ISessionToolPolicy, sessionToolPolicyStub()), - stubPair(ISessionAgentProfileCatalog, agentProfileCatalogStub()), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + ...agentProfileLoaderStubs(), + stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()), stubPair(IWorkspaceService, workspaceStub()), stubPair(ISessionIndex, sessionIndexStub()), stubPair(IAppendLogStore, appendLogStoreStub()), stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), stubPair(IAgentLifecycleService, agentLifecycleStub()), - stubPair(ISessionMcpService, sessionMcpServiceStub()), + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub()), stubPair(IConfigService, configStub()), stubPair(ISessionCronService, { _serviceBrand: undefined } as unknown as ISessionCronService), stubPair(ISessionSecondaryModelWarningService, { @@ -480,11 +567,18 @@ describe('SessionLifecycleService', () => { getSecondaryModelWarning: () => undefined, } as ISessionSecondaryModelWarningService), stubPair(IProjectLocalConfigService, projectLocalConfigStub()), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, cronStoreStub()), ...extra, ]); - return host.app.accessor.get(ISessionLifecycleService); + const lifecycle = host.app.accessor.get(IWorkspaceLifecycleService); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + return handler.accessor.get(IWorkspaceHandlerService); } async function makeTmpRoot(): Promise { @@ -494,7 +588,7 @@ describe('SessionLifecycleService', () => { } it('create / get / list / close', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(h.id).toBe('s1'); expect(svc.get('s1')).toBe(h); @@ -505,24 +599,39 @@ describe('SessionLifecycleService', () => { }); it('create seeds identity and materializes metadata', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(h.kind).toBe(LifecycleScope.Session); }); - it('create forwards caller-supplied MCP servers to the session MCP initial load', async () => { - const ensureMcpReady = vi.fn(() => Promise.resolve()); - const svc = build([ - stubPair(ISessionMcpService, sessionMcpServiceStub(ensureMcpReady)), - ]); - const mcpServers = { docs: { transport: 'http', url: 'https://mcp.example.com' } } as const; - await svc.create({ sessionId: 's1', workDir: '/tmp/proj', mcpServers }); - expect(ensureMcpReady).toHaveBeenCalledWith(mcpServers); + it('re-arms the explicit agent-profile loader after a fatal ready rejection so a later create succeeds', async () => { + let readyAttempts = 0; + const reload = vi.fn(() => Promise.resolve()); + const loaderStub = { + ...agentProfileLoaderStub(), + get ready(): Promise { + readyAttempts += 1; + return readyAttempts === 1 + ? Promise.reject(new Error('fatal explicit file')) + : Promise.resolve(); + }, + reload, + } satisfies IExplicitAgentProfileLoader; + const svc = await build([stubPair(IExplicitAgentProfileLoader, loaderStub)]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toThrow( + 'fatal explicit file', + ); + expect(reload).toHaveBeenCalledTimes(1); + expect(svc.get('s1')).toBeUndefined(); + + await svc.create({ sessionId: 's2', workDir: '/tmp/proj' }); + expect(svc.get('s2')).toBeDefined(); }); it('create appends the session to the shared session_index.jsonl', async () => { const appended: unknown[] = []; - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (scope: string, key: string, record: unknown) => { @@ -533,10 +642,10 @@ describe('SessionLifecycleService', () => { const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - // The index entry addresses the session under the registry-resolved - // workspace id — the same id seeding the session's storage scope — not a - // recomputed encodeWorkDirKey, so the v1 reader finds it in the bucket it - // was materialized into. + // The index entry addresses the session under the handler's workspace id + // — the same id seeding the session's storage scope — not a recomputed + // encodeWorkDirKey, so the v1 reader finds it in the bucket it was + // materialized into. const workspaceId = handle.accessor.get(ISessionContext).workspaceId; expect(appended).toEqual([ { @@ -555,7 +664,7 @@ describe('SessionLifecycleService', () => { const appended: unknown[] = []; const remove = vi.fn(() => Promise.resolve()); const create = vi.fn(() => Promise.reject(new Error('Unknown agent profile'))); - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (_scope: string, _key: string, record: unknown) => appended.push(record), @@ -582,7 +691,7 @@ describe('SessionLifecycleService', () => { it('indexes the session under the registry-resolved id when the workDir is an alias spelling', async () => { const appended: unknown[] = []; - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (scope: string, key: string, record: unknown) => { @@ -624,7 +733,7 @@ describe('SessionLifecycleService', () => { const workDir = '/tmp/proj'; const workspaces = persistentWorkspaceStub(); const sessionIndex = sessionIndexWithSummary('s1', workDir); - const first = build([ + const first = await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), ]); @@ -636,7 +745,7 @@ describe('SessionLifecycleService', () => { host?.dispose(); host = undefined; - const second = build([ + const second = await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -649,7 +758,7 @@ describe('SessionLifecycleService', () => { it('resumes from the persisted cwd when the workspace registry entry is missing', async () => { const workDir = '/tmp/proj'; - const svc = build([ + const svc = await build([ stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -662,8 +771,8 @@ describe('SessionLifecycleService', () => { }); it('does not cache a session whose tool policy fails to initialize', async () => { - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(ISessionToolPolicy, { ...sessionToolPolicyStub(), ready: Promise.reject(new Error('invalid tool policy')), @@ -705,13 +814,13 @@ describe('SessionLifecycleService', () => { update: () => Promise.resolve(undefined), delete: () => Promise.resolve(), }; - const svc = build([ + await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir, indexedWorkspaceId)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); - const resumed = await svc.resume('s1'); + const resumed = await resumeSessionById(host!.app.accessor, 's1'); const ctx = resumed?.accessor.get(ISessionContext); expect(ctx?.cwd).toBe(workDir); @@ -729,7 +838,7 @@ describe('SessionLifecycleService', () => { accessor: { get: () => ({}) }, dispose: () => {}, } as unknown as IAgentScopeHandle; - const svc = build([ + const svc = await build([ stubPair(ISessionMetadata, { ...metadataStub(), setArchived: (value: boolean) => { @@ -765,8 +874,8 @@ describe('SessionLifecycleService', () => { it('restore clears the archived flag when the session exists on disk', async () => { let archived: boolean | undefined; - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), stubPair(ISessionMetadata, { ...metadataStub(), @@ -803,18 +912,7 @@ describe('SessionLifecycleService', () => { }, dispose: () => {}, } as unknown as IAgentScopeHandle; - const svc = build([ - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), + const svc = await build([ stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), list: () => [busyAgent], @@ -830,7 +928,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidCreateSession with the new handle', async () => { - const svc = build(); + const svc = await build(); let captured: { readonly sessionId: string } | undefined; svc.onDidCreateSession((e) => { captured = e; @@ -840,7 +938,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_started with resumed: false and the bound session id on create', async () => { - const svc = build(); + const svc = await build(); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(telemetryRecords).toContainEqual({ event: 'session_started', @@ -849,7 +947,7 @@ describe('SessionLifecycleService', () => { }); it('keeps telemetry session context isolated when multiple sessions emit interleaved events', async () => { - const svc = build(); + const svc = await build(); const first = await svc.create({ sessionId: 'first', workDir: '/tmp/proj' }); const second = await svc.create({ sessionId: 'second', workDir: '/tmp/proj' }); telemetryRecords.length = 0; @@ -876,7 +974,7 @@ describe('SessionLifecycleService', () => { it('emits session_started with resumed: true and the bound session id on resume', async () => { const workDir = '/tmp/proj'; - const svc = build([ + const svc = await build([ stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -891,7 +989,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_load_failed with the bound session id and the error code when resume fails, then rethrows', async () => { - const svc = build([ + const svc = await build([ stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.reject(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'index read failed')), @@ -906,7 +1004,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_load_failed with the bound session id and the error name for plain errors', async () => { - const svc = build([ + const svc = await build([ stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.reject(new TypeError('bad index')), @@ -928,7 +1026,7 @@ describe('SessionLifecycleService', () => { ScopeActivation.OnScopeCreated, 'externalHooks', ); - const svc = build(); + const svc = await build(); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); await svc.close('s1'); @@ -941,8 +1039,8 @@ describe('SessionLifecycleService', () => { const mcpReady = new Promise((resolve) => { resolveMcpReady = resolve; }); - const svc = build([ - stubPair(ISessionMcpService, sessionMcpServiceStub(() => mcpReady)), + const svc = await build([ + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub(mcpReady)), ]); let settled = false; @@ -963,10 +1061,10 @@ describe('SessionLifecycleService', () => { const mcpReady = new Promise((resolve) => { resolveMcpReady = resolve; }); - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), - stubPair(ISessionMcpService, sessionMcpServiceStub(() => mcpReady)), + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub(mcpReady)), ]); const resumed = svc.resume('s1'); @@ -984,7 +1082,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidCloseSession when a session is closed', async () => { - const svc = build(); + const svc = await build(); const closed: string[] = []; svc.onDidCloseSession((e) => closed.push(e.sessionId)); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -993,7 +1091,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidArchiveSession when a session is archived', async () => { - const svc = build([ + const svc = await build([ stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), _serviceBrand: undefined, @@ -1032,7 +1130,7 @@ describe('SessionLifecycleService', () => { } it('loads project-local additional dirs into the session workspace on create', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), ]); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -1040,7 +1138,7 @@ describe('SessionLifecycleService', () => { }); it('merges caller additionalDirs and resolves relative paths against workDir', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj', @@ -1050,7 +1148,7 @@ describe('SessionLifecycleService', () => { }); it('deduplicates project-local and caller dirs after resolving', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/shared'])), ]); const h = await svc.create({ @@ -1062,7 +1160,7 @@ describe('SessionLifecycleService', () => { }); it('supports multiple project-local and caller additionalDirs', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/a', '/tmp/b'])), ]); const h = await svc.create({ @@ -1081,23 +1179,12 @@ describe('SessionLifecycleService', () => { dispose: () => {}, } as unknown as IAgentScopeHandle; const summary = { id: 's1', workspaceId: 'wd_stub' } as SessionSummary; - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), get: () => mainHandle, @@ -1111,19 +1198,8 @@ describe('SessionLifecycleService', () => { }); it('fork inherits project-local dirs', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1133,7 +1209,7 @@ describe('SessionLifecycleService', () => { }); it('create mints a session_-prefixed lowercase id when none is supplied', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ workDir: '/tmp/proj' }); expect(h.id).toMatch(/^session_[0-9a-f-]{36}$/); @@ -1142,19 +1218,7 @@ describe('SessionLifecycleService', () => { }); it('fork mints a session_-prefixed lowercase id when newSessionId is omitted', async () => { - const svc = build([ - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), - ]); + const svc = await build(); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); const target = await svc.fork({ sourceSessionId: 'src' }); @@ -1166,25 +1230,10 @@ describe('SessionLifecycleService', () => { }); describe('fork session state', () => { - function workspaceGetStub(): ReturnType { - return stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }); - } - it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1244,9 +1293,8 @@ describe('SessionLifecycleService', () => { }, disabledTools: () => disabledTools, } satisfies ISessionToolPolicy; - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, bootstrap), - workspaceGetStub(), stubPair(ISessionToolPolicy, policy), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1261,9 +1309,8 @@ describe('SessionLifecycleService', () => { it('rolls back the target session when fork fails after materializing', async () => { const root = await makeTmpRoot(); const srcDir = join(root, 'sessions', 'wd_stub', 'src'); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), stubPair(ISessionMetadata, { ...metadataStub(), read: () => @@ -1307,9 +1354,8 @@ describe('SessionLifecycleService', () => { }, { id: 'task-untagged', cron: '* * * * *', prompt: 'x', createdAt: 1 }, ]); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), stubPair(ICronTaskPersistence, cron), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1328,7 +1374,7 @@ describe('SessionLifecycleService', () => { describe('defaultPlanMode bootstrap', () => { it('enters plan mode on a fresh session when config.defaultPlanMode is true', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({ defaultPlanMode: true })), stubPair(IAgentLifecycleService, lifecycle), ]); @@ -1341,7 +1387,7 @@ describe('SessionLifecycleService', () => { it('leaves plan mode inactive when config.defaultPlanMode is absent', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({})), stubPair(IAgentLifecycleService, lifecycle), ]); @@ -1358,14 +1404,13 @@ describe('SessionLifecycleService', () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy({ mainPreexists: true, }); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({ defaultPlanMode: true })), stubPair(IAgentLifecycleService, lifecycle), stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceService, persistentWorkspaceStub()), ]); await svc.resume('s1'); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts new file mode 100644 index 000000000..9ef2fbe28 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts @@ -0,0 +1,170 @@ +/** + * Scenario: workspace AGENTS.md instructions — build-time snapshot and + * watch-driven refresh. + * + * Exercises the real `WorkspaceInstructionsService` against real temp + * instruction files with a manually-fired fs-watch stub. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceInstructions/instructions.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; + +import { stubLog } from '../../_base/log/stubs'; + +describe('WorkspaceInstructionsService', () => { + let workDir: string; + let osHomeDir: string; + let brandHomeDir: string; + let disposables: DisposableStore; + let watchFires: Map>; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-work-')); + osHomeDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-os-')); + brandHomeDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-brand-')); + disposables = new DisposableStore(); + watchFires = new Map(); + }); + + afterEach(async () => { + disposables.dispose(); + await Promise.all([ + rm(workDir, { recursive: true, force: true }), + rm(osHomeDir, { recursive: true, force: true }), + rm(brandHomeDir, { recursive: true, force: true }), + ]); + }); + + function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (path: string): IHostFsWatchHandle => { + let emitter = watchFires.get(path); + if (emitter === undefined) { + emitter = new Emitter(); + watchFires.set(path, emitter); + } + return { onDidChange: emitter.event, dispose: () => {} }; + }, + }; + } + + function fireWatch(path: string): void { + // The service watches each plan ROOT (brand home, real home, project + // root) recursively — fire on the root that contains the changed file. + for (const [root, emitter] of watchFires) { + if (path === root || path.startsWith(`${root}/`)) { + emitter.fire({ path, action: 'modified', kind: 'file' }); + } + } + } + + function createService(): IWorkspaceInstructionsService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd: workDir }); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IHostEnvironment, { homeDir: osHomeDir }); + reg.definePartialInstance(IBootstrapService, { homeDir: brandHomeDir }); + reg.defineInstance(IHostFsWatchService, fsWatchStub()); + reg.defineInstance(ILogService, stubLog()); + reg.define(IWorkspaceInstructionsService, WorkspaceInstructionsService); + }, + }); + return ix.get(IWorkspaceInstructionsService); + } + + it('loads the AGENTS.md snapshot at build and projects it through the session provider', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf8'); + await writeFile(join(brandHomeDir, 'AGENTS.md'), 'brand instructions', 'utf8'); + + const service = createService(); + await service.ready; + + expect(service.snapshot.agentsMd).toContain('brand instructions'); + expect(service.snapshot.agentsMd).toContain('project instructions'); + const provider = service.sessionProvider(); + expect(provider.agentsMd).toBe(service.snapshot.agentsMd); + expect(provider.agentsMdWarning).toBeUndefined(); + }); + + it('refreshes the snapshot and fires onDidChange when a watched file changes', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'old instructions', 'utf8'); + const service = createService(); + await service.ready; + expect(service.snapshot.agentsMd).toContain('old instructions'); + + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'new instructions', 'utf8'); + fireWatch(file); + await changed; + + expect(service.snapshot.agentsMd).toContain('new instructions'); + expect(service.snapshot.agentsMd).not.toContain('old instructions'); + }); + + it('picks up a newly created AGENTS.md through the watch', async () => { + const service = createService(); + await service.ready; + expect(service.snapshot.agentsMd).toBe(''); + + const file = join(workDir, 'AGENTS.md'); + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'created later', 'utf8'); + fireWatch(file); + await changed; + + expect(service.snapshot.agentsMd).toContain('created later'); + }); + + it('does not fire when a reload produces identical content', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'stable', 'utf8'); + const service = createService(); + await service.ready; + + let fired = 0; + service.onDidChange(() => { + fired += 1; + }); + fireWatch(file); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); + + expect(fired).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts new file mode 100644 index 000000000..f5b405e93 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts @@ -0,0 +1,165 @@ +/** + * Scenario: workspace MCP initialization — config-readiness gating and the + * global `[mcp]` timeout preferences, end to end. + * + * Exercises the real `WorkspaceMcpService` + `WorkspaceMcpConfigService` + * through DI against real temp config files and stdio fixture servers. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcp/initialization.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; + +import { stubLog } from '../../_base/log/stubs'; +import { + createMemoryMcpOAuthStore, + slowToolStdioFixture, + stdioFixture, +} from '../../mcpCore/stubs'; + +describe('Workspace MCP initialization', () => { + let cwd: string; + let homeDir: string; + let disposables: DisposableStore; + let manager: McpConnectionManager | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); + homeDir = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-home-')); + disposables = new DisposableStore(); + manager = undefined; + }); + + afterEach(async () => { + await manager?.shutdown(); + disposables.dispose(); + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(homeDir, { recursive: true, force: true }), + ]); + }); + + function createWorkspaceMcpService(ready: Promise, mcpSection?: McpSection) { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IBootstrapService, { homeDir }); + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.definePartialInstance(IPluginService, { + enabledMcpServers: async () => ({}), + onDidReload: Event.None as Event, + }); + reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore()); + reg.defineInstance(ILogService, stubLog()); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.definePartialInstance(IConfigService, { + ready, + get: ((domain: string): T => + (domain === MCP_SECTION ? mcpSection : undefined) as T), + }); + reg.definePartialInstance(IHostFsWatchService, { + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IWorkspaceTrust, { + ready: Promise.resolve(), + isTrusted: () => true, + onDidChange: Event.None as IWorkspaceTrust['onDidChange'], + }); + reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService); + reg.define(IWorkspaceMcpService, WorkspaceMcpService); + }, + }); + return ix.get(IWorkspaceMcpService); + } + + it('exposes the connection manager before config is ready and starts connecting once ready', async () => { + let resolveConfigReady!: () => void; + const ready = new Promise((resolve) => { + resolveConfigReady = resolve; + }); + await writeProjectMcpJson(cwd, { + alpha: { + transport: 'stdio', + command: process.execPath, + args: [stdioFixture], + }, + }); + const service = createWorkspaceMcpService(ready); + // The manager is available synchronously, independent of config readiness. + manager = service.connectionManager(); + expect(manager.list()).toEqual([]); + + // The initial connect is gated on config.ready: no entry exists yet. + await sleep(50); + expect(manager.list()).toEqual([]); + + resolveConfigReady(); + await service.ready; + expect(manager.get('alpha')?.status).toBe('connected'); + }, 15000); + + it('times out tool calls using the session MCP timeout preference', async () => { + await writeProjectMcpJson(cwd, { + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, + }, + }); + const service = createWorkspaceMcpService(Promise.resolve(), { toolTimeoutMs: 1 }); + await service.ready; + manager = service.connectionManager(); + const client = manager.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + }, 15000); +}); + +async function writeProjectMcpJson( + cwd: string, + servers: Record, +): Promise { + await mkdir(join(cwd, '.kimi-code'), { recursive: true }); + await writeFile( + join(cwd, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: servers }), + 'utf8', + ); +} diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts new file mode 100644 index 000000000..37a5eeb3f --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -0,0 +1,172 @@ +/** + * Scenario: workspace MCP — the shared connection manager is driven by the + * config domain: the initial connect consumes its snapshot, and its diffed + * change events are applied incrementally after the initial connect settles. + * + * Exercises the real `WorkspaceMcpService` against a stubbed + * `IWorkspaceMcpConfigService` and real stdio fixture servers. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcp/workspaceMcp.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, + type McpTunables, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; + +import { stubLog } from '../../_base/log/stubs'; +import { createMemoryMcpOAuthStore, stdioFixture } from '../../mcpCore/stubs'; + +function stdioServer(): McpServerConfig { + return { transport: 'stdio', command: process.execPath, args: [stdioFixture] }; +} + +describe('WorkspaceMcpService', () => { + let cwd: string; + let disposables: DisposableStore; + let current: Record; + let tunablesValue: McpTunables; + let tunablesFn: Mock<() => McpTunables>; + let configChanges: Emitter; + let manager: InstanceType | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-cwd-')); + disposables = new DisposableStore(); + current = {}; + tunablesValue = {}; + tunablesFn = vi.fn(() => tunablesValue); + configChanges = new Emitter(); + manager = undefined; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await manager?.shutdown(); + disposables.dispose(); + await rm(cwd, { recursive: true, force: true }); + }); + + function mcpConfigStub(): IWorkspaceMcpConfigService { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + servers: () => current, + tunables: () => tunablesFn() as McpTunables, + onDidChange: configChanges.event, + }; + } + + function createService(): IWorkspaceMcpService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.defineInstance(IWorkspaceMcpConfigService, mcpConfigStub()); + reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore()); + reg.defineInstance(ILogService, stubLog()); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.define(IWorkspaceMcpService, WorkspaceMcpService); + }, + }); + return ix.get(IWorkspaceMcpService); + } + + it('connects the config snapshot in the initial load', async () => { + current = { alpha: stdioServer(), beta: stdioServer() }; + const connectAll = vi + .spyOn(McpConnectionManager.prototype, 'connectAll') + .mockResolvedValue(undefined); + + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + expect(connectAll).toHaveBeenCalledTimes(1); + expect(Object.keys(connectAll.mock.calls[0]?.[0] ?? {}).toSorted()).toEqual(['alpha', 'beta']); + }); + + it('reads timeout tunables from the config domain at connect', async () => { + tunablesValue = { startupTimeoutMs: 4321, toolTimeoutMs: 9876 }; + current = { alpha: stdioServer() }; + + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + expect(manager.get('alpha')?.status).toBe('connected'); + expect(tunablesFn).toHaveBeenCalled(); + }, 20000); + + it('applies upserts and removals from config change events', async () => { + current = { alpha: stdioServer() }; + const service = createService(); + manager = service.connectionManager(); + await service.ready; + expect(manager.get('alpha')?.status).toBe('connected'); + + configChanges.fire({ upsert: { beta: stdioServer() }, remove: ['alpha'] }); + + await vi.waitFor( + () => { + expect(manager?.get('alpha')).toBeUndefined(); + expect(manager?.get('beta')?.status).toBe('connected'); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('queues change events until the initial connect settles', async () => { + current = { alpha: stdioServer() }; + let settleConnectAll: () => void = () => undefined; + vi.spyOn(McpConnectionManager.prototype, 'connectAll').mockImplementation( + () => + new Promise((resolve) => { + settleConnectAll = resolve; + }), + ); + const connect = vi + .spyOn(McpConnectionManager.prototype, 'connect') + .mockResolvedValue(undefined as never); + const remove = vi + .spyOn(McpConnectionManager.prototype, 'remove') + .mockResolvedValue(undefined as never); + + const service = createService(); + manager = service.connectionManager(); + + configChanges.fire({ upsert: { beta: stdioServer() }, remove: ['alpha'] }); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 300)); + expect(connect).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + + settleConnectAll(); + await service.ready; + await vi.waitFor( + () => { + expect(remove).toHaveBeenCalledWith('alpha'); + expect(connect).toHaveBeenCalledWith('beta', stdioServer()); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); +}); diff --git a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts similarity index 78% rename from packages/agent-core-v2/test/agent/mcp/config-loader.test.ts rename to packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts index 29759bc10..c7426ce35 100644 --- a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts @@ -3,7 +3,7 @@ * * Exercises the real loader against temporary JSON files. Run with `pnpm * --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/agent/mcp/config-loader.test.ts`. + * test/workspace/workspaceMcpConfig/config-loader.test.ts`. */ import { mkdtempSync } from 'node:fs'; @@ -13,7 +13,10 @@ import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import { ErrorCodes, Error2 } from '#/errors'; -import { loadMcpServers, resolveMcpJsonPaths } from '#/agent/mcp/config-loader'; +import { loadMcpServers, resolveMcpJsonPaths } from '#/workspace/workspaceMcpConfig/config-loader'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; + +const fs = new HostFileSystem(); const tempDirs: string[] = []; @@ -41,7 +44,7 @@ describe('resolveMcpJsonPaths', () => { await mkdir(join(repoRoot, '.git'), { recursive: true }); await mkdir(cwd, { recursive: true }); - const paths = await resolveMcpJsonPaths({ cwd, homeDir: '/home/user/.kimi-code' }); + const paths = await resolveMcpJsonPaths({ fs, cwd, homeDir: '/home/user/.kimi-code' }); expect(paths.user).toBe('/home/user/.kimi-code/mcp.json'); expect(paths.projectRoot).toBe(join(repoRoot, '.mcp.json')); @@ -53,7 +56,7 @@ describe('loadMcpServers', () => { it('returns an empty map when no files exist', async () => { const home = makeTempDir(); const cwd = makeTempDir(); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers).toEqual({}); }); @@ -61,7 +64,7 @@ describe('loadMcpServers', () => { const home = makeTempDir(); const cwd = makeTempDir(); await writeFile(join(home, 'mcp.json'), ' \n'); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers).toEqual({}); }); @@ -82,7 +85,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(Object.keys(servers).toSorted()).toEqual(['local', 'shared', 'userOnly']); expect(servers['shared']).toEqual({ @@ -99,6 +102,41 @@ describe('loadMcpServers', () => { }); }); + it('loads only the user file when includeProject is false (untrusted workspace)', async () => { + const home = makeTempDir(); + const repoRoot = makeTempDir(); + const cwd = join(repoRoot, 'packages', 'agent-core'); + await mkdir(join(repoRoot, '.git'), { recursive: true }); + await mkdir(cwd, { recursive: true }); + + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-user' }, + userOnly: { transport: 'stdio', command: 'user-only' }, + }, + }); + await writeJson(join(repoRoot, '.mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-root' }, + rootOnly: { command: 'root-only' }, + }, + }); + await writeJson(join(cwd, '.kimi-code', 'mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-project' }, + projectOnly: { transport: 'http', url: 'https://mcp.example.com' }, + }, + }); + + const servers = await loadMcpServers({ fs, cwd, homeDir: home, includeProject: false }); + + expect(Object.keys(servers).toSorted()).toEqual(['shared', 'userOnly']); + expect(servers['shared']).toEqual({ + transport: 'stdio', + command: 'shared-user', + }); + }); + it('loads root .mcp.json from the repo root and lets project-local override it', async () => { const home = makeTempDir(); const repoRoot = makeTempDir(); @@ -125,7 +163,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(Object.keys(servers).toSorted()).toEqual([ 'projectOnly', @@ -159,7 +197,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['implicitRoot']).toEqual({ transport: 'stdio', @@ -191,8 +229,8 @@ describe('loadMcpServers', () => { const home = makeTempDir(); const cwd = makeTempDir(); await writeFile(join(home, 'mcp.json'), '{not json}', 'utf-8'); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toBeInstanceOf(Error2); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toBeInstanceOf(Error2); + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -203,7 +241,7 @@ describe('loadMcpServers', () => { await writeJson(join(home, 'mcp.json'), { mcpServers: { bad: { transport: 'websocket', url: 'https://x.example.com' } }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -214,7 +252,7 @@ describe('loadMcpServers', () => { await writeJson(join(home, 'mcp.json'), { mcpServers: { bad: { transport: 'stdio' } }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -232,7 +270,7 @@ describe('loadMcpServers', () => { }, }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -250,7 +288,7 @@ describe('loadMcpServers', () => { }, }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).resolves.toEqual({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).resolves.toEqual({ boundary: { transport: 'stdio', command: 'node', @@ -268,7 +306,7 @@ describe('loadMcpServers', () => { gh: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['gh']).toEqual({ transport: 'stdio', command: 'npx', @@ -284,7 +322,7 @@ describe('loadMcpServers', () => { remote: { url: 'https://mcp.example.com/sse' }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['remote']).toEqual({ transport: 'http', url: 'https://mcp.example.com/sse', @@ -304,7 +342,7 @@ describe('loadMcpServers', () => { }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['legacy']).toEqual({ transport: 'sse', url: 'https://mcp.example.com/sse', @@ -322,7 +360,7 @@ describe('loadMcpServers', () => { const saved = process.env['KIMI_CODE_HOME']; process.env['KIMI_CODE_HOME'] = home; try { - const servers = await loadMcpServers({ cwd }); + const servers = await loadMcpServers({ fs, cwd }); expect(servers['from_env']).toEqual({ transport: 'stdio', command: 'env-cmd' }); } finally { if (saved === undefined) delete process.env['KIMI_CODE_HOME']; diff --git a/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts new file mode 100644 index 000000000..7ede8521c --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts @@ -0,0 +1,300 @@ +/** + * Scenario: workspace MCP config — the initial file+plugin merge (file wins + * name collisions) and watch/plugin-reload-driven reconciliation published + * as already-diffed change events. + * + * Exercises the real `WorkspaceMcpConfigService` against real temp config + * files with a manually-fired fs-watch stub. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceTrust, + type WorkspaceTrustChange, +} from '#/workspace/workspaceTrust/workspaceTrust'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; + +import { stubLog } from '../../_base/log/stubs'; + +function stdioConfig(command: string): McpServerConfig { + return { transport: 'stdio', command, args: [] }; +} + +describe('WorkspaceMcpConfigService', () => { + let cwd: string; + let homeDir: string; + let disposables: DisposableStore; + let watchFires: Map>; + let pluginServers: Record; + let pluginReloads: Emitter; + let trusted: boolean; + let trustFlips: Emitter; + let changes: McpServersChange[]; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-config-cwd-')); + homeDir = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-config-home-')); + disposables = new DisposableStore(); + watchFires = new Map(); + pluginServers = {}; + pluginReloads = new Emitter(); + trusted = true; + trustFlips = new Emitter(); + changes = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + disposables.dispose(); + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(homeDir, { recursive: true, force: true }), + ]); + }); + + function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (path: string): IHostFsWatchHandle => { + let emitter = watchFires.get(path); + if (emitter === undefined) { + emitter = new Emitter(); + watchFires.set(path, emitter); + } + return { onDidChange: emitter.event, dispose: () => {} }; + }, + }; + } + + function createService(mcpSection?: McpSection): IWorkspaceMcpConfigService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IBootstrapService, { homeDir }); + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.definePartialInstance(IPluginService, { + enabledMcpServers: async () => pluginServers, + onDidReload: pluginReloads.event, + }); + reg.defineInstance(ILogService, stubLog()); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: ((domain: string): T => + (domain === MCP_SECTION ? mcpSection : undefined) as T), + }); + reg.defineInstance(IHostFsWatchService, fsWatchStub()); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IWorkspaceTrust, { + ready: Promise.resolve(), + isTrusted: () => trusted, + onDidChange: trustFlips.event, + }); + reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService); + }, + }); + const service = ix.get(IWorkspaceMcpConfigService); + service.onDidChange((change) => changes.push(change)); + return service; + } + + async function writeProjectConfig(servers: Record): Promise { + const dir = join(cwd, '.kimi-code'); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'mcp.json'); + await writeFile(file, JSON.stringify({ mcpServers: servers }), 'utf8'); + return file; + } + + it('merges file and plugin servers in the initial resolve (file wins name collisions)', async () => { + await writeProjectConfig({ shared: stdioConfig('file-version'), fileOnly: stdioConfig('file') }); + pluginServers = { shared: stdioConfig('plugin-version'), pluginOnly: stdioConfig('plugin') }; + + const service = createService(); + await service.ready; + + expect(service.servers()).toEqual({ + shared: stdioConfig('file-version'), + fileOnly: stdioConfig('file'), + pluginOnly: stdioConfig('plugin'), + }); + expect(changes).toEqual([]); + }); + + it('skips the project-level config files while the workspace is untrusted', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + pluginServers = { pluginOnly: stdioConfig('plugin') }; + trusted = false; + + const service = createService(); + await service.ready; + + expect(service.servers()).toEqual({ pluginOnly: stdioConfig('plugin') }); + }); + + it('picks up the project servers when the workspace becomes trusted', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + trusted = false; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({}); + + trusted = true; + trustFlips.fire({ trusted: true }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: { fileOnly: stdioConfig('file') }, remove: [] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ fileOnly: stdioConfig('file') }); + }, 20000); + + it('drops the project servers when the workspace loses trust', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + pluginServers = { pluginOnly: stdioConfig('plugin') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ + fileOnly: stdioConfig('file'), + pluginOnly: stdioConfig('plugin'), + }); + + trusted = false; + trustFlips.fire({ trusted: false }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: {}, remove: ['fileOnly'] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ pluginOnly: stdioConfig('plugin') }); + }, 20000); + + it('exposes the [mcp] section as tunables, resolved live', async () => { + const service = createService({ startupTimeoutMs: 1234, toolTimeoutMs: 5678 }); + await service.ready; + + expect(service.tunables()).toEqual({ startupTimeoutMs: 1234, toolTimeoutMs: 5678 }); + }); + + it('publishes the diff when a watched config file changes', async () => { + const file = await writeProjectConfig({ alpha: stdioConfig('alpha') }); + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ alpha: stdioConfig('alpha') }); + + await writeProjectConfig({ beta: stdioConfig('beta') }); + watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); + + await vi.waitFor( + () => { + expect(changes).toEqual([ + { upsert: { beta: stdioConfig('beta') }, remove: ['alpha'] }, + ]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ beta: stdioConfig('beta') }); + }, 20000); + + it('falls back to the same-named plugin server when a file server vanishes', async () => { + const file = await writeProjectConfig({ shared: stdioConfig('file-version') }); + pluginServers = { shared: stdioConfig('plugin-version') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + + await writeProjectConfig({}); + watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); + + await vi.waitFor( + () => { + expect(changes).toEqual([ + { upsert: { shared: stdioConfig('plugin-version') }, remove: [] }, + ]); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('publishes a plugin server that appears on plugin reload', async () => { + const service = createService(); + await service.ready; + + pluginServers = { gamma: stdioConfig('gamma') }; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: { gamma: stdioConfig('gamma') }, remove: [] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ gamma: stdioConfig('gamma') }); + }, 20000); + + it('removes a plugin server that vanishes on plugin reload', async () => { + pluginServers = { alpha: stdioConfig('alpha') }; + const service = createService(); + await service.ready; + + pluginServers = {}; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: {}, remove: ['alpha'] }]); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('stays silent when a vanished plugin server leaves the same-named file entry in place', async () => { + await writeProjectConfig({ shared: stdioConfig('file-version') }); + pluginServers = { shared: stdioConfig('plugin-version') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + + pluginServers = {}; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + // The merged view is unchanged (the file entry still wins), so no event + // fires and the snapshot stays put. + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); + expect(changes).toEqual([]); + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + }, 20000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts new file mode 100644 index 000000000..8f26c497c --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { WorkspaceProcessRunnerService } from '#/workspace/workspaceProcess/workspaceProcessRunnerService'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +describe('WorkspaceProcessRunnerService', () => { + let dir: string; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IHostProcessService, + HostProcessService, + ScopeActivation.OnDemand, + 'hostProcess', + ); + registerScopedService( + LifecycleScope.Workspace, + ISessionProcessRunner, + WorkspaceProcessRunnerService, + ScopeActivation.OnDemand, + 'workspaceProcess', + ); + dir = await mkdtemp(join(tmpdir(), 'procrunner-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function makeRunner(): Promise { + const host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w', [ + stubPair(IWorkspaceContext, { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: dir, + source: 'local', + meta: { id: 'w', root: dir, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + } satisfies IWorkspaceContext), + ]); + return workspace.accessor.get(ISessionProcessRunner); + } + + it('exec runs a command and captures stdout + exit code', async () => { + const runner = await makeRunner(); + const proc = await runner.exec(['node', '-e', 'process.stdout.write("ok")']); + const out = await collect(proc.stdout); + expect(out).toBe('ok'); + expect(await proc.wait()).toBe(0); + expect(proc.exitCode).toBe(0); + }); + + it('exec overlays per-call env', async () => { + const runner = await makeRunner(); + const proc = await runner.exec( + ['node', '-e', 'process.stdout.write(process.env.FOO ?? "")'], + { env: { FOO: 'bar' } }, + ); + const out = await collect(proc.stdout); + expect(out).toBe('bar'); + expect(await proc.wait()).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts new file mode 100644 index 000000000..db2e24d1a --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -0,0 +1,475 @@ +/** + * Scenario: workspace resource sharing across concurrent sessions (the + * phase-3 behavior contract). + * + * Drives the REAL handler chain (WorkspaceLifecycleService → + * WorkspaceHandlerService) with the real Workspace-scope resource services + * and proves: one shared MCP connection manager (and one initial connect) + * for two sessions of the same workspace, no skill rescan when the second + * session lists skills, and the fs-watch fan-out refreshing a live session's + * skill list after `.agents/skills` changes on disk. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceResources.test.ts`. + */ + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; +import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import '#/session/agentLifecycle/profile/profiles'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; +import { IMcpOAuthStore, McpOAuthStoreAdapter } from '#/app/mcpConfig/oauthStore'; +import { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +import { IPluginSkillSource, PluginSkillSource } from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; + +import { stubLog } from '../_base/log/stubs'; +import { stubSkill } from '../app/skillCatalog/stubs'; +import { stdioFixture } from '../mcpCore/stubs'; + +function workspaceCatalogStub(): IWorkspaceService { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const workspace: Workspace = workspaces.get(id) ?? { + id, + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + +function pluginStub(): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: Event.None, + enabledMcpServers: async () => ({}), + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => [], + } as unknown as IPluginService; +} + +class TrustedWorkspaceTrustStub implements IWorkspaceTrust { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + readonly onDidChange = Event.None as IWorkspaceTrust['onDidChange']; + isTrusted(): boolean { + return true; + } + get(): Promise { + return Promise.resolve(true); + } + trust(): Promise { + return Promise.resolve(); + } + untrust(): Promise { + return Promise.resolve(); + } +} + +describe('workspace resource sharing (handler chain)', () => { + let host: ScopedTestHost | undefined; + let tmpRoots: string[]; + + beforeEach(() => { + _clearScopedRegistryForTests(); + tmpRoots = []; + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', + ); + registerScopedService(LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceAgentProfileLoader, + WorkspaceAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', + ); + registerScopedService(LifecycleScope.Workspace, IExtraAgentProfileLoader, ExtraAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IExplicitAgentProfileLoader, ExplicitAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IUserAgentProfileLoader, UserAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IPluginAgentProfileLoader, PluginAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceInstructionsService, + WorkspaceInstructionsService, + ScopeActivation.OnScopeCreated, + 'workspaceInstructions', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpService, + WorkspaceMcpService, + ScopeActivation.OnScopeCreated, + 'workspaceMcp', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpConfigService, + WorkspaceMcpConfigService, + ScopeActivation.OnScopeCreated, + 'workspaceMcpConfig', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceTrust, + TrustedWorkspaceTrustStub, + ScopeActivation.OnDemand, + 'workspaceTrust', + ); + registerScopedService( + LifecycleScope.App, + IMcpOAuthStore, + McpOAuthStoreAdapter, + ScopeActivation.OnDemand, + 'mcpConfig', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService, ScopeActivation.OnScopeCreated, 'sessionSkillCatalog'); + registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); + registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); + registerScopedService(LifecycleScope.App, IAgentProfileRegistry, AgentProfileRegistryService, ScopeActivation.OnDemand, 'agentProfileCatalog'); + registerScopedService(LifecycleScope.App, IBuiltinAgentProfileLoader, BuiltinAgentProfileLoaderService, ScopeActivation.OnDemand, 'agentProfileCatalog'); + }); + + afterEach(async () => { + host?.dispose(); + host = undefined; + await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tmpRoots.push(root); + return root; + } + + function buildHost(discovery: ISkillDiscovery, homeDir: string): void { + host = createScopedTestHost([ + stubPair(IBootstrapService, { + _serviceBrand: undefined, + homeDir, + osHomeDir: homeDir, + scope: (name: string) => name, + } as unknown as IBootstrapService), + stubPair(IHostEnvironment, { + _serviceBrand: undefined, + osKind: 'Linux', + homeDir, + ready: Promise.resolve(), + } as unknown as IHostEnvironment), + stubPair(IHostFileSystem, new HostFileSystem()), + stubPair(IHostFsWatchService, new HostFsWatchService()), + stubPair(ILogService, stubLog()), + stubPair(IConfigService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(ITelemetryService, noopTelemetryService), + stubPair(ISkillDiscovery, discovery), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IAgentCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as IAgentCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IWorkspaceService, workspaceCatalogStub()), + stubPair(ISessionIndex, { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + } as unknown as ISessionIndex), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => Event.None, + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } as unknown as IEventService), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + } as unknown as ICronTaskPersistence), + stubPair(IProjectLocalConfigService, { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [], + }), + resolveAdditionalDirs: (_base: string, dirs: readonly string[]) => + Promise.resolve([...dirs]), + } as unknown as IProjectLocalConfigService), + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } as unknown as ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } as unknown as ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IAgentLifecycleService, { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService), + ]); + } + + async function handlerFor(root: string): Promise { + const handler = await (host as ScopedTestHost).app.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root }); + return handler.accessor.get(IWorkspaceHandlerService); + } + + it('runs one shared MCP manager and one initial connect for two concurrent sessions', async () => { + const root = await makeRoot('kimi-ws-mcp-'); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + join(root, '.kimi-code', 'mcp.json'), + JSON.stringify({ + mcpServers: { + alpha: { transport: 'stdio', command: process.execPath, args: [stdioFixture] }, + }, + }), + 'utf8', + ); + const home = await makeRoot('kimi-ws-mcp-home-'); + buildHost(new InMemorySkillDiscovery(), home); + const connectAll = vi.spyOn(McpConnectionManager.prototype, 'connectAll'); + + const svc = await handlerFor(root); + const [s1, s2] = await Promise.all([ + svc.create({ sessionId: 's1', workDir: root }), + svc.create({ sessionId: 's2', workDir: root }), + ]); + + const m1 = s1.accessor.get(ISessionMcpHandle); + const m2 = s2.accessor.get(ISessionMcpHandle); + expect(m1.connectionManager).toBe(m2.connectionManager); + expect(connectAll).toHaveBeenCalledTimes(1); + expect(m1.connectionManager.get('alpha')?.status).toBe('connected'); + }, 20000); + + it('does not rescan skills when a second session of the workspace lists them', async () => { + const root = await makeRoot('kimi-ws-skill-'); + await mkdir(join(root, '.kimi-code', 'skills'), { recursive: true }); + const home = await makeRoot('kimi-ws-skill-home-'); + const counting = new InMemorySkillDiscovery(); + counting.setProjectSkills([stubSkill('project-skill')]); + let discoverCalls = 0; + const discovery: ISkillDiscovery = { + _serviceBrand: undefined, + discover: async (roots) => { + discoverCalls += 1; + return counting.discover(roots); + }, + }; + buildHost(discovery, home); + + const svc = await handlerFor(root); + const s1 = await svc.create({ sessionId: 's1', workDir: root }); + const c1 = s1.accessor.get(ISessionSkillCatalog); + await c1.ready; + expect(c1.catalog.getSkill('project-skill')).toBeDefined(); + const callsAfterFirst = discoverCalls; + expect(callsAfterFirst).toBeGreaterThan(0); + + const s2 = await svc.create({ sessionId: 's2', workDir: root }); + const c2 = s2.accessor.get(ISessionSkillCatalog); + await c2.ready; + expect(c2.catalog.getSkill('project-skill')).toBeDefined(); + expect(discoverCalls).toBe(callsAfterFirst); + }, 20000); + + it('refreshes a live session skill list when .agents/skills changes on disk', async () => { + const root = await makeRoot('kimi-ws-watch-'); + const home = await makeRoot('kimi-ws-watch-home-'); + buildHost(new FileSkillDiscovery(stubLog()), home); + + const svc = await handlerFor(root); + const s1 = await svc.create({ sessionId: 's1', workDir: root }); + const catalog = s1.accessor.get(ISessionSkillCatalog); + await catalog.ready; + expect(catalog.catalog.getSkill('watched-skill')).toBeUndefined(); + + await mkdir(join(root, '.agents', 'skills', 'watched-skill'), { recursive: true }); + await writeFile( + join(root, '.agents', 'skills', 'watched-skill', 'SKILL.md'), + '---\nname: watched-skill\ndescription: from watch\n---\nbody', + 'utf8', + ); + + await vi.waitFor( + () => { + expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch'); + }, + // Real FSEvents delivery + the 200 ms source debounce + a real disk + // rescan: under high parallel load the 10 s budget flakes, so allow 30 s. + { timeout: 30000, interval: 100 }, + ); + }, 60000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts new file mode 100644 index 000000000..6931f4c3c --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -0,0 +1,836 @@ +/** + * Scenario: workspace skill-source discovery, merge, and plugin refresh. + * + * Exercises the real Workspace-scoped catalog and source services with + * filesystem or in-memory discovery boundaries, including controlled + * concurrent refreshes and the fs-watch-driven single-source rescan. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceSkillCatalog/skillCatalog.test.ts`. + */ + +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { + _clearScopedRegistryForTests, + LifecycleScope, + registerScopedService, +} from '#/_base/di/scope'; +import { Emitter, Event } from '#/_base/event'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IPluginService } from '#/app/plugin/plugin'; +import { PluginService } from '#/app/plugin/pluginService'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { IProviderService } from '#/kosong/provider/provider'; +import { IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IConfigService } from '#/app/config/config'; +import { + EXTRA_SKILL_DIRS_SECTION, + MERGE_ALL_AVAILABLE_SKILLS_SECTION, +} from '#/app/skillCatalog/configSection'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; +import type { SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; +import { IPluginSkillSource, PluginSkillSource } from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; +import type { SkillRoot } from '#/app/skillCatalog/types'; +import { ILogService } from '#/_base/log/log'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; + +import { stubBootstrap } from '../../app/bootstrap/stubs'; +import { stubSkill } from '../../app/skillCatalog/stubs'; +import { stubProviderService } from '../../app/provider/stubs'; +import { stubLog } from '../../_base/log/stubs'; + +const bootstrapStub = stubBootstrap('/home'); + +function configStub(): IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; +} { + let extraSkillDirs: readonly string[] = []; + let mergeAllAvailableSkills = true; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; + return undefined; + }, + inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraSkillDirs: (dirs: readonly string[]) => { + extraSkillDirs = [...dirs]; + }, + setMergeAllAvailableSkills: (value: boolean) => { + mergeAllAvailableSkills = value; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; + }; +} + +function pluginStub( + skillRoots: readonly SkillRoot[] = [], + reloadEmitter?: Emitter, +): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => skillRoots, + pluginAgentRoots: async () => [], + enabledSessionStarts: async () => [], + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function workspaceContextStub(workDir: string): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'wd_test', + cwd: workDir, + source: 'local', + meta: { id: 'wd_test', root: workDir, name: 'test', createdAt: 0, lastOpenedAt: 0 }, + persistenceScope: `sessions/wd_test`, + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }; +} + +function makeHost( + store: ISkillDiscovery, + ws: IWorkspaceContext, + pluginRoots: readonly SkillRoot[] = [], + explicitDirs?: readonly string[], + pluginReloadEmitter?: Emitter, +) { + const config = configStub(); + const runtimeOptions = { + _serviceBrand: undefined, + explicitDirs, + } as unknown as ISkillCatalogRuntimeOptions; + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + return { host, workspace, config }; +} + +function waitForEvents(event: Event, count: number): Promise { + return new Promise((resolve) => { + let received = 0; + const disposable = event(() => { + received += 1; + if (received === count) { + disposable.dispose(); + resolve(); + } + }); + }); +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function withSkillCatalogWorkspace( + run: (fixture: { readonly workDir: string; readonly skillRoot: string }) => Promise, +): Promise { + const workDir = await mkdtemp(join(tmpdir(), 'skill-catalog-')); + const skillRoot = join(workDir, '.kimi-code', 'skills'); + await mkdir(skillRoot, { recursive: true }); + try { + await run({ workDir, skillRoot: await realpath(skillRoot) }); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +describe('WorkspaceSkillCatalogService', () => { + beforeEach(() => { + // Keep the scoped registry limited to the catalog chain these tests + // exercise so unrelated OnScopeCreated registrations do not run; every + // other dependency arrives as a seeded stub via `createScopedTestHost`. + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); + registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); + registerScopedService(LifecycleScope.App, IPluginService, PluginService); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ); + registerScopedService(LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource); + registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource); + registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource); + registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource); + }); + + it('merges global and project skills; project wins on name collision', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([ + stubSkill('global-only'), + stubSkill('shared', { description: 'from user' }), + ]); + store.setProjectSkills([ + stubSkill('project-only'), + stubSkill('shared', { description: 'from project' }), + ]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const names = catalog.catalog.listSkills().map((s) => s.name); + expect(names).toContain('global-only'); + expect(names).toContain('project-only'); + expect(names).toContain('shared'); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); + host.dispose(); + }); + + it('orders project, user and plugin skills as project > user > plugin', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([ + stubSkill('shared', { description: 'from user' }), + stubSkill('user-plugin', { description: 'from user' }), + ]); + store.setProjectSkills([stubSkill('shared', { description: 'from project' })]); + store.setExtraSkills([ + stubSkill('shared', { description: 'from extra', source: 'extra' }), + stubSkill('user-plugin', { description: 'from extra', source: 'extra' }), + stubSkill('extra-plugin', { description: 'from extra', source: 'extra' }), + ]); + store.setPluginSkills([ + stubSkill('shared', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + stubSkill('user-plugin', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + stubSkill('extra-plugin', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + ]); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace, config } = makeHost(store, ws, [pluginRoot]); + config.setExtraSkillDirs(['/']); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); + expect(catalog.catalog.getSkill('user-plugin')?.description).toBe('from user'); + expect(catalog.catalog.getSkill('extra-plugin')?.description).toBe('from extra'); + host.dispose(); + }); + + it('replaces default user and project discovery with explicitDirs', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('from-explicit', { description: 'from explicit' })]); + store.setProjectSkills([stubSkill('project-only', { description: 'from project' })]); + store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); + store.setPluginSkills([ + stubSkill('plugin-only', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + ]); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace, config } = makeHost(store, ws, [pluginRoot], ['/']); + config.setExtraSkillDirs(['/']); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit'); + expect(catalog.catalog.getSkill('project-only')).toBeUndefined(); + expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); + expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin'); + host.dispose(); + }); + + it('waits for config ready before loading extra skill dirs', async () => { + let markReady!: () => void; + let ready = false; + const configReady = new Promise((resolve) => { + markReady = () => { + ready = true; + resolve(); + }; + }); + const config = { + ...configStub(), + ready: configReady, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return ready ? ['/'] : []; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; + return undefined; + }, + } as unknown as IConfigService; + const store = new InMemorySkillDiscovery(); + store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); + const runtimeOptions = { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + let settled = false; + const loading = catalog.load().then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled).toBe(false); + + markReady(); + await loading; + + expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); + host.dispose(); + }); + + it('reloads user and workspace sources when mergeAllAvailableSkills changes', async () => { + class CountingDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + calls = 0; + async discover() { + this.calls++; + return { skills: [], skipped: [], scannedRoots: [] }; + } + } + const store = new CountingDiscovery(); + const config = configStub(); + const runtimeOptions = { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + const afterLoad = store.calls; + + const reloaded = waitForEvents(catalog.onDidChange, 2); + config.fireSectionChange(MERGE_ALL_AVAILABLE_SKILLS_SECTION); + await reloaded; + + expect(store.calls).toBe(afterLoad + 2); + host.dispose(); + }); + + it('reload re-scans every source and replaces the merged view', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('global-only')]); + store.setProjectSkills([stubSkill('first')]); + const ws = workspaceContextStub('/work1'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('first')).toBeDefined(); + + store.setProjectSkills([stubSkill('second')]); + const changes: string[] = []; + const subscription = catalog.onDidChange((sourceId) => changes.push(sourceId)); + await catalog.reload(); + + expect(catalog.catalog.getSkill('first')).toBeUndefined(); + expect(catalog.catalog.getSkill('second')).toBeDefined(); + expect(catalog.catalog.getSkill('global-only')).toBeDefined(); + expect(changes).toEqual(['catalog']); + subscription.dispose(); + host.dispose(); + }); + + it('does not rescan on subsequent load calls without change events', async () => { + const store = new InMemorySkillDiscovery(); + store.setProjectSkills([stubSkill('first')]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + store.setProjectSkills([stubSkill('second')]); + await catalog.load(); + + expect(catalog.catalog.getSkill('first')).toBeDefined(); + expect(catalog.catalog.getSkill('second')).toBeUndefined(); + host.dispose(); + }); + + it('passes plugin skill roots to the store so plugin skills are discoverable', async () => { + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo', instructions: 'Use the demo tools.' }, + }; + class ExtraRootStore implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + receivedRoots: readonly SkillRoot[] | undefined; + async discover(roots: readonly SkillRoot[]) { + if (roots.some((root) => root.plugin !== undefined)) { + this.receivedRoots = roots; + } + const pluginSkills = roots + .filter((root) => root.plugin !== undefined) + .map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin })); + return { skills: pluginSkills, skipped: [], scannedRoots: [] }; + } + } + const store = new ExtraRootStore(); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws, [pluginRoot]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(store.receivedRoots).toEqual([pluginRoot]); + expect(catalog.catalog.getSkill('demo-skill')?.plugin?.id).toBe('demo'); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + host.dispose(); + }); + + it('feeds scanned roots from file sources into the merged catalog', async () => { + await withSkillCatalogWorkspace(async ({ workDir, skillRoot }) => { + class RootDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + async discover(roots: readonly SkillRoot[]) { + return { + skills: [], + skipped: [], + scannedRoots: roots + .filter((root) => root.source === 'project') + .map((root) => root.path), + }; + } + } + const ws = workspaceContextStub(workDir); + const { host, workspace } = makeHost(new RootDiscovery(), ws); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkillRoots()).toEqual([skillRoot]); + } finally { + host.dispose(); + } + }); + }); + + it('feeds skipped skills from file sources into the merged catalog', async () => { + await withSkillCatalogWorkspace(async ({ workDir }) => { + const skippedEntry = { + path: join(workDir, '.kimi-code', 'skills', 'bad', 'SKILL.md'), + type: 'nope', + reason: 'unsupported skill type "nope"', + }; + class SkippingDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + async discover(roots: readonly SkillRoot[]) { + const isProject = roots.some((root) => root.source === 'project'); + return { + skills: [], + skipped: isProject ? [skippedEntry] : [], + scannedRoots: [], + }; + } + } + const ws = workspaceContextStub(workDir); + const { host, workspace } = makeHost(new SkippingDiscovery(), ws); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkippedByPolicy()).toEqual([skippedEntry]); + } finally { + host.dispose(); + } + }); + }); + + it('fires onDidChange with the plugin source id after a plugin reload re-pulls plugin skills', async () => { + const store = new InMemorySkillDiscovery(); + store.setPluginSkills([ + stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), + ]); + const reloadEmitter = new Emitter(); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws, [pluginRoot], undefined, reloadEmitter); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + + const refreshed = new Promise((resolve) => { + const d = catalog.onDidChange((sourceId) => { + d.dispose(); + resolve(sourceId); + }); + }); + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + + await expect(refreshed).resolves.toBe('plugin'); + } finally { + host.dispose(); + reloadEmitter.dispose(); + } + }); + + it('queues a plugin refresh during initial load so the refreshed contribution remains active', async () => { + const initialLoad = deferred(); + const refreshedLoad = deferred(); + const initialStarted = deferred(); + const refreshedStarted = deferred(); + const sourceChanges = new Emitter(); + let loadCount = 0; + const pluginSource: IPluginSkillSource = { + _serviceBrand: undefined, + id: 'plugin', + priority: 5, + onDidChange: sourceChanges.event, + load: () => { + loadCount += 1; + if (loadCount === 1) { + initialStarted.resolve(undefined); + return initialLoad.promise; + } + if (loadCount === 2) { + refreshedStarted.resolve(undefined); + return refreshedLoad.promise; + } + throw new Error('unexpected plugin source load'); + }, + }; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + stubPair(IPluginSkillSource, pluginSource), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + const loading = catalog.load(); + await initialStarted.promise; + const sourceIds: string[] = []; + const refreshed = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + sourceIds.push(sourceId); + if (sourceId === 'plugin') { + subscription.dispose(); + resolve(); + } + }); + }); + + sourceChanges.fire(); + + expect(loadCount).toBe(1); + + initialLoad.resolve({ + skills: [ + stubSkill('stale-skill', { source: 'extra', plugin: { id: 'demo' } }), + ], + }); + await refreshedStarted.promise; + refreshedLoad.resolve({ + skills: [ + stubSkill('fresh-skill', { source: 'extra', plugin: { id: 'demo' } }), + ], + }); + await Promise.all([loading, refreshed]); + + expect(sourceIds).toEqual(['plugin']); + expect(catalog.catalog.getPluginSkill('demo', 'stale-skill')).toBeUndefined(); + expect(catalog.catalog.getPluginSkill('demo', 'fresh-skill')).toBeDefined(); + } finally { + host.dispose(); + sourceChanges.dispose(); + } + }); + + it('binds thisArg when forwarding plugin reloads through the plugin skill source', async () => { + const reloadEmitter = new Emitter(); + const pluginService = pluginStub([], reloadEmitter); + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginService), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + ]); + + try { + const source = workspace.accessor.get(IPluginSkillSource); + void source.id; + const receiver = { tag: 'receiver' }; + const seen: unknown[] = []; + const subscription = source.onDidChange?.( + function (this: unknown) { + seen.push(this); + }, + receiver, + ); + + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + + expect(seen).toEqual([receiver]); + subscription?.dispose(); + } finally { + host.dispose(); + reloadEmitter.dispose(); + } + }); + + it('keeps non-plugin skills working and recovers plugin skills after a corrupt installed.json is fixed and reloaded', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'plugin-home-')); + await mkdir(join(homeDir, 'plugins'), { recursive: true }); + await writeFile(join(homeDir, 'plugins', 'installed.json'), '{ not json', 'utf8'); + + const managedRoot = join(homeDir, 'plugins', 'managed', 'demo'); + await mkdir(join(managedRoot, 'skills', 'demo-skill'), { recursive: true }); + await writeFile( + join(managedRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'demo', skills: './skills/' }), + 'utf8', + ); + await writeFile( + join(managedRoot, 'skills', 'demo-skill', 'SKILL.md'), + '---\nname: demo-skill\ndescription: demo\n---\nbody', + 'utf8', + ); + + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('global-only')]); + store.setPluginSkills([ + stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), + ]); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IProviderService, stubProviderService()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const ws = workspaceContextStub('/work'); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('global-only')).toBeDefined(); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeUndefined(); + + await writeFile( + join(homeDir, 'plugins', 'installed.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'demo', + root: managedRoot, + source: 'local-path', + enabled: true, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + 'utf8', + ); + const refreshed = new Promise((resolve) => { + const d = catalog.onDidChange((sourceId) => { + if (sourceId === 'plugin') { + d.dispose(); + resolve(); + } + }); + }); + await host.app.accessor.get(IPluginService).reloadPlugins(); + await refreshed; + + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } + }); + it('rescans the workspace-root source when a project skill file changes on disk', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-')); + const host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + stubPair(IHostFsWatchService, new HostFsWatchService()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, workspaceContextStub(workDir)), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('watched-skill')).toBeUndefined(); + + const refreshed = new Promise((resolvePromise) => { + const d = catalog.onDidChange((sourceId) => { + d.dispose(); + resolvePromise(sourceId); + }); + }); + const timedOut = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('watch-driven refresh timed out')), 10000); + }); + await mkdir(join(workDir, '.agents', 'skills', 'watched-skill'), { recursive: true }); + await writeFile( + join(workDir, '.agents', 'skills', 'watched-skill', 'SKILL.md'), + '---\nname: watched-skill\ndescription: from watch\n---\nbody', + 'utf8', + ); + + await expect(Promise.race([refreshed, timedOut])).resolves.toBe('workspace'); + expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch'); + } finally { + host.dispose(); + await rm(workDir, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts new file mode 100644 index 000000000..6fe3dcf92 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts @@ -0,0 +1,66 @@ +/** + * `workspaceToolPolicy` domain (L2) — verifies the capability-derived veto + * set and the `ISessionToolPolicyGate` live read view the handler seeds into + * every session. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { LifecycleScope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { + WorkspaceToolPolicyService, + computeCapabilityDisabledTools, +} from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; + +void WorkspaceToolPolicyService; + +const WORK_DIR = '/repo'; + +function stubWorkspaceContext(osBackendId = 'local'): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId, + persistenceBackendId: 'local', + }; +} + +let host: ScopedTestHost | undefined; + +afterEach(() => { + host?.dispose(); + host = undefined; +}); + +function makePolicy(osBackendId = 'local'): IWorkspaceToolPolicy { + host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext(osBackendId)), + ]); + return workspace.accessor.get(IWorkspaceToolPolicy); +} + +describe('computeCapabilityDisabledTools', () => { + it('disables nothing for the local os backend', () => { + expect(computeCapabilityDisabledTools('local')).toEqual([]); + }); +}); + +describe('WorkspaceToolPolicyService', () => { + it('exposes an empty veto set on the local runtime', () => { + expect(makePolicy().disabledTools()).toEqual([]); + }); + + it('hands sessions a live gate view over the same veto set', () => { + const gate = makePolicy().sessionGate(); + expect(gate.disabledTools).toEqual([]); + expect(gate.onDidChange).toBeDefined(); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts new file mode 100644 index 000000000..d21b6d734 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -0,0 +1,130 @@ +/** + * Scenario: workspace trust — explicit trust/untrust flips persisted outside + * the workspace, idempotency and the change event, per-root independence, + * and marker survival across a restart. + * + * Exercises the real `WorkspaceTrustService` against the real node-fs + * `JsonAtomicDocumentStore` over a temp home. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceTrust/workspaceTrust.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceTrust, + type WorkspaceTrustChange, +} from '#/workspace/workspaceTrust/workspaceTrust'; +import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService'; + +describe('WorkspaceTrustService', () => { + let homeDir: string; + let cwd: string; + let disposables: DisposableStore; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-home-')); + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-cwd-')); + disposables = new DisposableStore(); + }); + + afterEach(async () => { + disposables.dispose(); + await Promise.all([ + rm(homeDir, { recursive: true, force: true }), + rm(cwd, { recursive: true, force: true }), + ]); + }); + + function createService(root: string, events?: WorkspaceTrustChange[]): IWorkspaceTrust { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd: root }); + reg.defineInstance( + IAtomicDocumentStore, + new JsonAtomicDocumentStore(new FileStorageService(homeDir)), + ); + reg.define(IWorkspaceTrust, WorkspaceTrustService); + }, + }); + const service = ix.get(IWorkspaceTrust); + if (events !== undefined) { + service.onDidChange((change) => events.push(change)); + } + return service; + } + + it('defaults to untrusted when no marker exists', async () => { + const service = createService(cwd); + await service.ready; + + expect(service.isTrusted()).toBe(false); + expect(await service.get()).toBe(false); + }); + + it('trust() flips the state, fires once, and stays idempotent', async () => { + const events: WorkspaceTrustChange[] = []; + const service = createService(cwd, events); + await service.ready; + + await service.trust(); + await service.trust(); + + expect(service.isTrusted()).toBe(true); + expect(await service.get()).toBe(true); + expect(events).toEqual([{ trusted: true }]); + }); + + it('untrust() revokes the state and both directions stay idempotent', async () => { + const events: WorkspaceTrustChange[] = []; + const service = createService(cwd, events); + await service.ready; + + await service.untrust(); + await service.trust(); + await service.untrust(); + await service.untrust(); + + expect(service.isTrusted()).toBe(false); + expect(events).toEqual([{ trusted: true }, { trusted: false }]); + }); + + it('keeps the marker across a restart', async () => { + const first = createService(cwd); + await first.ready; + await first.trust(); + + const second = createService(cwd); + await second.ready; + + expect(second.isTrusted()).toBe(true); + }); + + it('tracks different roots independently', async () => { + const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-')); + try { + const first = createService(cwd); + await first.ready; + await first.trust(); + + const second = createService(other); + await second.ready; + + expect(second.isTrusted()).toBe(false); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kap-server/src/openapi/transforms.ts b/packages/kap-server/src/openapi/transforms.ts index ffc27d497..fdd2a4b6e 100644 --- a/packages/kap-server/src/openapi/transforms.ts +++ b/packages/kap-server/src/openapi/transforms.ts @@ -33,7 +33,7 @@ import { fsStatManyResponseSchema, fsStatRequestSchema, fsStatResponseSchema, -} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { z } from 'zod'; import { diff --git a/packages/kap-server/src/protocol/rest-fs.ts b/packages/kap-server/src/protocol/rest-fs.ts index 113c4cbb3..adc47d545 100644 --- a/packages/kap-server/src/protocol/rest-fs.ts +++ b/packages/kap-server/src/protocol/rest-fs.ts @@ -1,7 +1,8 @@ /** * The `fs:open` / `fs:open_in` / `fs:reveal` request schemas — the only fs - * wire shapes the engine does not own (the `sessionFs` domain in agent-core-v2 - * holds the rest). Also home of `fsOpenInAppIdSchema`, referenced by the + * wire shapes the engine does not own (the `workspaceFs` domain in + * agent-core-v2 holds the rest). Also home of `fsOpenInAppIdSchema`, + * referenced by the * `/v1/meta` capabilities document. */ diff --git a/packages/kap-server/src/protocol/rest-workspace.ts b/packages/kap-server/src/protocol/rest-workspace.ts index 73d9a9dfc..0fefd7f47 100644 --- a/packages/kap-server/src/protocol/rest-workspace.ts +++ b/packages/kap-server/src/protocol/rest-workspace.ts @@ -3,6 +3,9 @@ * POST /v1/workspaces * PATCH /v1/workspaces/{workspace_id} * DELETE /v1/workspaces/{workspace_id} + * GET /v1/workspaces/{workspace_id}/trust + * POST /v1/workspaces/{workspace_id}/trust + * POST /v1/workspaces/{workspace_id}/untrust */ import { z } from 'zod'; @@ -40,3 +43,8 @@ export const deleteWorkspaceResponseSchema = z.object({ deleted: z.literal(true), }); export type DeleteWorkspaceResponse = z.infer; + +export const workspaceTrustResponseSchema = z.object({ + trusted: z.boolean(), +}); +export type WorkspaceTrustResponse = z.infer; diff --git a/packages/kap-server/src/routes/approvals.ts b/packages/kap-server/src/routes/approvals.ts index 9e5966948..b60fc2970 100644 --- a/packages/kap-server/src/routes/approvals.ts +++ b/packages/kap-server/src/routes/approvals.ts @@ -31,7 +31,7 @@ import { ISessionApprovalService, ISessionInteractionService, - ISessionLifecycleService, + resumeSessionById, type ApprovalRequest, type ApprovalResponse, type Interaction, @@ -101,7 +101,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -135,7 +135,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id, approval_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 4a55cd33d..d01a9cb54 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -3,18 +3,22 @@ * * Mirrors `packages/server/src/routes/fs.ts` path-for-path and schema-for-schema * so existing v1 clients keep working against server-v2. Backed by the v2 - * Session-scoped `ISessionFsService` (`agent-core-v2/src/sessionFs`): the route resolves - * the session from the URL, then dispatches `fs:` to the matching - * `ISessionFsService` method. The wire schema comes from the engine's own - * `sessionFs` domain contract (`agent-core-v2`). + * Workspace-scoped `IWorkspaceFsService` (`agent-core-v2/src/workspace/workspaceFs`): + * the route resolves the session from the URL, then dispatches `fs:` + * to the matching `IWorkspaceFsService` method — the session's accessor + * resolves it from its parent Workspace scope (the handler), which is the + * "session → handler → workspace fs" chain (chdir is gone, so the handler + * root is the one fixed fs root). The wire schema comes from the engine's own + * `workspaceFs` domain contract (`agent-core-v2`). */ import { createReadStream } from 'node:fs'; import { ErrorCodes, - ISessionFsService, - ISessionLifecycleService, + IWorkspaceFsService, + getLiveSessionById, + resumeSessionById, isError2, Error2, type Scope, @@ -30,7 +34,7 @@ import { fsSearchRequestSchema, fsStatManyRequestSchema, fsStatRequestSchema, -} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -99,12 +103,14 @@ const FS_ACTIONS = [ type FsAction = (typeof FS_ACTIONS)[number]; const FS_TAIL_PREFIX = 'fs:'; -function resolveFs(core: Scope, sessionId: string): ISessionFsService { - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); +function resolveFs(core: Scope, sessionId: string): IWorkspaceFsService { + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } - return session.accessor.get(ISessionFsService); + // The fs service lives on the session's parent Workspace scope (the + // handler): one instance per workspace, pinned to the handler root. + return session.accessor.get(IWorkspaceFsService); } export function registerFsRoutes(app: FsRouteHost, core: Scope): void { @@ -154,7 +160,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // need the work dir) do not 404 on a freshly-opened session. Matches v1, // which reads the persisted cwd. `resume` returns undefined only when the // session is unknown or its workspace is gone. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -251,7 +257,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // Cold-load so a freshly-opened (persisted but not live) session can still // serve downloads; `resume` only returns undefined for unknown / workspace-gone. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -259,7 +265,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { return; } - let resolved: Awaited>; + let resolved: Awaited>; try { resolved = await resolveFs(core, session_id).resolveDownload(relPath); } catch (err) { @@ -332,7 +338,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } // --------------------------------------------------------------------------- -// Action handlers — thin adapters: parse body, call ISessionFsService, wrap result. +// Action handlers — thin adapters: parse body, call IWorkspaceFsService, wrap result. // --------------------------------------------------------------------------- type Req = { id: string; body: unknown }; @@ -531,7 +537,7 @@ function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void case ErrorCodes.SESSION_NOT_FOUND: reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); return; - // hostFs errors that escaped the sessionFs layer keep their `os.fs.*` + // hostFs errors that escaped the workspaceFs layer keep their `os.fs.*` // code; map them onto the closest v1 wire code (ENOTDIR collapses into // path-not-found, matching `mapFsError`). case ErrorCodes.OS_FS_NOT_FOUND: diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 2520ad63a..d5a0cca39 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -30,7 +30,7 @@ import { type PromptHandle, type PromptQueueSnapshot, ISessionContext, - ISessionLifecycleService, + resumeSessionById, ITelemetryService, applyPromptMetadataUpdate, buildImageCompressionCaption, @@ -110,7 +110,7 @@ async function resolveSession(core: Scope, sessionId: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) return undefined; return sessionMediaOriginalsDir(session.accessor.get(ISessionContext).sessionDir); }, resolveAttachmentsDir: async () => { - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) return undefined; return join(session.accessor.get(ISessionContext).sessionDir, 'attachments'); }, diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts index 1ba3ef413..c642e53d4 100644 --- a/packages/kap-server/src/routes/questions.ts +++ b/packages/kap-server/src/routes/questions.ts @@ -45,7 +45,7 @@ import { type Interaction, ISessionInteractionService, ISessionQuestionService, - ISessionLifecycleService, + resumeSessionById, type QuestionAnswers, type QuestionItem, type QuestionOption, @@ -122,7 +122,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -171,7 +171,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo const questionId = parsed.id; const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1623004e0..8bf59b8c0 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -18,13 +18,15 @@ * * The `POST /sessions/{tail}` actions split into two groups. The thin * pass-throughs — `fork` / `compact` / `abort` / `archive` / `restore` — call - * the native v2 services directly (`ISessionLifecycleService.fork` / `archive` / `restore`, + * the native v2 services directly (the workspace handler's + * `IWorkspaceHandlerService.fork` / `archive` / `restore`, reached through the + * `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` composition, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows * `ISessionLegacyService.status` for the cross-domain status rollup. The - * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` + * `/sessions/{id}/children` endpoints call `IWorkspaceHandlerService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the * index. Only `POST /sessions/{id}/profile` (`updateProfile`), @@ -85,13 +87,17 @@ import { ISessionBtwService, ISessionContext, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, ISessionLegacyService, ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, IWorkspaceService, + getLiveSessionById, + handlerForSession, + resumeSessionById, isError2, Error2, toProtocolMessage, @@ -310,11 +316,17 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } // Ensure the workspace is registered so `metadata.cwd` is resolvable on - // read (gap G3 — v2 does not store workDir on the session). + // read (gap G3 — v2 does not store workDir on the session). The session + // is created through the workspace's handler (`handlerFor` → the + // handler's `IWorkspaceHandlerService`) — there is no App-scope session + // lifecycle entry point. try { const touched = await registry.createOrTouch(workDir); - const handle = await core.accessor.get(ISessionLifecycleService).create({ + const handler = await core.accessor.get(IWorkspaceLifecycleService).handlerFor({ + root: workDir, + }); + const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ workDir, }); if (typeof body.title === 'string') { @@ -648,9 +660,17 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'fork') { const body = forkSessionRequestSchema.parse(req.body); - // `lifecycle.fork` throws `session.not_found` for an unknown source, - // so no explicit existence check is needed here. - const handle = await core.accessor.get(ISessionLifecycleService).fork({ + // Fork lives on the source session's handler; the index routes us + // there (`session.not_found` for an unknown source, same as the + // lifecycle's own guard). + const forkHandler = await handlerForSession(core.accessor, parsed.id); + if (forkHandler === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${parsed.id} does not exist`, + ); + } + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ sourceSessionId: parsed.id, title: body.title, metadata: body.metadata, @@ -732,7 +752,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'btw') { // `resume` (not `get`) so a freshly-opened cold session can start a // side-channel agent; matches v1's `startBtw` which resumes first. - const session = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); + const session = await resumeSessionById(core.accessor, parsed.id); if (session === undefined) { throw new Error2( ErrorCodes.SESSION_NOT_FOUND, @@ -746,7 +766,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } if (parsed.action === 'restore') { - const restored = await core.accessor.get(ISessionLifecycleService).restore(parsed.id); + const restoreHandler = await handlerForSession(core.accessor, parsed.id); + const restored = + restoreHandler === undefined + ? undefined + : await restoreHandler.accessor.get(IWorkspaceHandlerService).restore(parsed.id); if (restored === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } @@ -765,11 +789,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // archive — `resume` (not `get`) so archiving a freshly-opened cold // session still works; `resume` returns undefined only when the session // is unknown or its workspace is gone, reported as `session.not_found`. - const archived = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); - if (archived === undefined) { + const archiveHandler = await handlerForSession(core.accessor, parsed.id); + const archived = + archiveHandler === undefined + ? undefined + : await archiveHandler.accessor.get(IWorkspaceHandlerService).resume(parsed.id); + if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - await core.accessor.get(ISessionLifecycleService).archive(parsed.id); + await archiveHandler.accessor.get(IWorkspaceHandlerService).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { @@ -803,7 +831,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // 404 when the parent is unknown — the live handle wins, otherwise the // persisted index (a closed parent can still list children, like v1). const exists = - core.accessor.get(ISessionLifecycleService).get(session_id) !== undefined || + getLiveSessionById(core.accessor, session_id) !== undefined || (await core.accessor.get(ISessionIndex).get(session_id)) !== undefined; if (!exists) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); @@ -887,8 +915,12 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // `createChild` throws `session.not_found` for an unknown source (via // `fork`), so no explicit existence check is needed here. The child // markers (`parent_session_id` / `child_session_kind`) and the default - // `Child: ` title are applied by the lifecycle. - const handle = await core.accessor.get(ISessionLifecycleService).createChild({ + // `Child: ` title are applied by the handler's lifecycle. + const childHandler = await handlerForSession(core.accessor, session_id); + if (childHandler === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); + } + const handle = await childHandler.accessor.get(IWorkspaceHandlerService).createChild({ sourceSessionId: session_id, title: req.body.title, metadata: req.body.metadata, @@ -991,7 +1023,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const { session_id } = req.params; // `resume` (not `get`) so a freshly-opened cold session still computes its // warnings; matches v1's best-effort `resumeSession` before reading them. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -1103,7 +1135,7 @@ export interface SessionFacts { * outcome. */ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFacts { - const handle = core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(core.accessor, sessionId); if (handle === undefined) { return { busy: false, @@ -1122,7 +1154,7 @@ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFact * `ISessionLegacyService`. */ async function resolveMainAgent(core: Scope, sessionId: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const session = await resumeSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 7c0a1045b..982125e0b 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -25,7 +25,8 @@ * are exported for exactly this purpose. * * **Activation gate**: by convention the session endpoints are only valid for - * an *activated* session — one that is live in `ISessionLifecycleService`. When + * an *activated* session — one that is live in a workspace handler's session + * registry. When * the session is not in the live map we still answer `40401 session.not_found` * (the only session error code on the v1 wire contract), but we enrich the * message: @@ -78,7 +79,6 @@ import { IEventService, IPluginService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, ISessionSkillCatalog, ISkillCatalogRuntimeOptions, @@ -86,6 +86,7 @@ import { IWorkspaceService, InMemorySkillCatalog, isError2, + resumeSessionById, MERGE_ALL_AVAILABLE_SKILLS_SECTION, SKILL_SOURCE_PRIORITY, applyPromptMetadataUpdate, @@ -161,7 +162,7 @@ async function resolveActivatedSession( // session cold-loads it instead of reporting "not activated"; matches v1's // `resumeSession` in SkillService. `resume` returns undefined only when the // session is unknown or its workspace is gone. - const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const handle = await resumeSessionById(core.accessor, sessionId); if (handle !== undefined) return { handle }; const summary = await core.accessor.get(ISessionIndex).get(sessionId); diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index ee75e8bf6..929b06caa 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -5,8 +5,8 @@ * * - `auto` (default) — delegate to `ISnapshotReader`, which reads * `state.json` + `agents/main/wire.jsonl` directly from disk and bypasses - * the heavy `ISessionLifecycleService.resume` chain (DI scope, MCP connect, - * full wire replay). Sub-200ms warm / sub-1s cold. + * the heavy session-resume chain (handler + DI scope materialization, MCP + * connect, full wire replay). Sub-200ms warm / sub-1s cold. * - `legacy` — fall back to `resume` + live service assembly. Pure operator * escape hatch; no silent per-request fallback. * @@ -25,9 +25,9 @@ import { ILogService, ISessionInteractionService, ISessionContext, - ISessionLifecycleService, ISessionMetadata, IWorkspaceService, + resumeSessionById, toProtocolMessage, type IAgentScopeHandle, type Scope, @@ -148,7 +148,7 @@ async function readViaLegacyAssembly( // Resolve the live handle, loading the session from disk when it is cold // (created by a previous process or by v1). `resume` returns `undefined` // only when the session is unknown or its workspace is gone → 404. - const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const handle = await resumeSessionById(core.accessor, sessionId); if (handle === undefined) { throw new SnapshotNotFoundError(sessionId); } diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index 3a664a471..e2f6e6804 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -18,7 +18,7 @@ * `packages/agent-core/src/services/task/task.ts`. * * **Resolution**: `core` → `ISessionIndex` (existence, → 40401) → - * `ISessionLifecycleService` (live session handle) → `IAgentLifecycleService` + * the live handler registry (live session handle) → `IAgentLifecycleService` * (the `main` agent) → `IAgentTaskService`. When the session is not live or * has no main agent yet (server-v2 gap G10 — the main agent is not created on * session creation), there is no task service: `list` returns an empty page @@ -41,7 +41,7 @@ import { IAgentTaskService, ISessionIndex, - ISessionLifecycleService, + getLiveSessionById, type AgentTaskInfo, type Scope, } from '@moonshot-ai/agent-core-v2'; @@ -279,7 +279,7 @@ async function resolveSessionTasks(core: Scope, sid: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const session = await resumeSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } diff --git a/packages/kap-server/src/routes/tools.ts b/packages/kap-server/src/routes/tools.ts index f729dcf35..a5d151a54 100644 --- a/packages/kap-server/src/routes/tools.ts +++ b/packages/kap-server/src/routes/tools.ts @@ -19,7 +19,7 @@ * the most-recent session. v2 has no global tool/MCP state — both services are * Agent-scoped — so we reproduce the fallback: `core` → `ISessionIndex` (pick * the newest session by `createdAt`, or the explicit `session_id`) → - * `ISessionLifecycleService` → `IAgentLifecycleService` (the `main` agent) → + * the live handler registry → `IAgentLifecycleService` (the `main` agent) → * the service. When no session is live, or the main agent does not exist yet * (server-v2 gap G10), the GET endpoints answer an empty list and `:restart` * answers `40408`, exactly like v1. @@ -51,9 +51,9 @@ import { ErrorCodes, IAgentMcpService, ISessionIndex, - ISessionLifecycleService, IAgentToolRegistryService, IAgentToolPolicyService, + getLiveSessionById, Error2, type Scope, type ToolInfo, @@ -222,7 +222,7 @@ export function registerToolsRoutes(app: ToolsRouteHost, core: Scope): void { async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined) { const sid = sessionId ?? (await mostRecentSessionId(core)); if (sid === undefined) return undefined; - const session = core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(core.accessor, sid); if (session === undefined) return undefined; return ensureMainAgent(session); } diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts index f10725c54..ff5902260 100644 --- a/packages/kap-server/src/routes/workspaceFs.ts +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -26,7 +26,7 @@ * All file handling lives here in the transport layer on top of the os * `IHostFileSystem` primitives — the engine deliberately has no "unconfined * read" domain Service. The mime / etag helpers are shared with the engine's - * `sessionFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label + * `workspaceFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label * content the same way. `IHostFileSystem` failures arrive as coded `os.fs.*` * errors and are mapped here: * diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index f6d9aca89..c32630503 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -10,6 +10,14 @@ * POST /workspaces register (idempotent on root) * PATCH /workspaces/{workspace_id} rename (display name only) * DELETE /workspaces/{workspace_id} unregister + * GET /workspaces/{workspace_id}/trust read the trust state + * POST /workspaces/{workspace_id}/trust mark the workspace trusted + * POST /workspaces/{workspace_id}/untrust revoke trust + * + * The trust routes resolve the workspace's live handler + * (`IWorkspaceLifecycleService.handlerFor`, materializing it on demand) and + * read/flip the Workspace-scope `IWorkspaceTrust`; while untrusted, the + * handler's project-level MCP config files are not loaded. * * **Wire fidelity**: the v1 `workspaceSchema` carries more fields than v2's * `Workspace` (`{ id, root, name, createdAt, lastOpenedAt }`). The handler @@ -24,8 +32,10 @@ import { IHostFileSystem, + IWorkspaceLifecycleService, IWorkspaceService, IWorkspaceSessions, + IWorkspaceTrust, type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; @@ -45,6 +55,7 @@ import { updateWorkspaceRequestSchema, updateWorkspaceResponseSchema, workspaceIdParamSchema, + workspaceTrustResponseSchema, } from '../protocol/rest-workspace'; import type { Workspace as WorkspaceWire } from '../protocol/workspace'; @@ -216,6 +227,101 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): deleteRoute.options, deleteRoute.handler as Parameters[2], ); + + const getTrustRoute = defineRoute( + { + method: 'GET', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Read the workspace trust state', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + reply.send(okEnvelope({ trusted: await trust.get() }, req.id)); + }, + ); + app.get( + getTrustRoute.path, + getTrustRoute.options, + getTrustRoute.handler as Parameters[2], + ); + + const trustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Mark the workspace trusted (project-level MCP config loads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.trust(); + reply.send(okEnvelope({ trusted: true }, req.id)); + }, + ); + app.post( + trustRoute.path, + trustRoute.options, + trustRoute.handler as Parameters[2], + ); + + const untrustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/untrust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Revoke workspace trust (project-level MCP config unloads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.untrust(); + reply.send(okEnvelope({ trusted: false }, req.id)); + }, + ); + app.post( + untrustRoute.path, + untrustRoute.options, + untrustRoute.handler as Parameters[2], + ); +} + +type TrustReply = { send(payload: unknown): unknown }; + +async function resolveTrust( + core: Scope, + workspaceId: string, + requestId: string, + reply: TrustReply, +): Promise { + const ws = await core.accessor.get(IWorkspaceService).get(workspaceId); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`, requestId), + ); + return undefined; + } + const handle = await core + .accessor.get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId, root: ws.root }); + return handle.accessor.get(IWorkspaceTrust); } // --------------------------------------------------------------------------- diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index 64f5c9178..b1f8a7842 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -43,6 +43,8 @@ import { LifecycleScope, ScopeActivation, registerScopedService, + sessionDirOf, + workspacePersistenceScope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { LockError, MiniDb, normalizeLiteral, tokenize, type BatchInputOp } from '@moonshot-ai/minidb'; @@ -780,7 +782,11 @@ export class GlobalSearchService implements IGlobalSearchService { } private async syncSession(db: MiniDb, summary: SessionSummary): Promise { - const sessionDir = this.bootstrap.sessionDir(summary.workspaceId, summary.id); + const sessionDir = sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ); const wireFiles = await collectWireFiles(sessionDir); const seenPaths = new Set(wireFiles.map((file) => file.path)); diff --git a/packages/kap-server/src/services/snapshot/snapshot.ts b/packages/kap-server/src/services/snapshot/snapshot.ts index 7f6bdb583..9e68c18cd 100644 --- a/packages/kap-server/src/services/snapshot/snapshot.ts +++ b/packages/kap-server/src/services/snapshot/snapshot.ts @@ -3,7 +3,7 @@ * `GET /sessions/{sid}/snapshot` in `auto` mode. * * Reads `state.json` + `agents/main/wire.jsonl` directly from disk, bypassing - * the `ISessionLifecycleService.resume` chain (DI-scope materialization, MCP + * the session-resume chain (handler + DI-scope materialization, MCP * connect, full wire replay). Mirrors v1's `ISnapshotService` * (`packages/server/src/services/snapshot/snapshot.ts`). */ diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 0723f10f9..f5ff963e7 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -3,8 +3,8 @@ * (`KIMI_SNAPSHOT_READER=auto`, the default). * * Reads `/sessions///state.json` and - * `…/agents/main/wire.jsonl` directly, bypassing - * `ISessionLifecycleService.resume` (DI-scope materialization, MCP connect, + * `…/agents/main/wire.jsonl` directly, bypassing the session-resume chain + * (handler materialization, DI-scope materialization, MCP connect, * full wire replay). The transcript is reduced from the `context.*` records * with `reduceContextTranscript`, which mirrors the live reducers EXCEPT that * `context.apply_compaction` keeps the full history and appends a summary @@ -26,12 +26,13 @@ import { IAgentPromptService, ISessionIndex, ISessionInteractionService, - ISessionLifecycleService, IWorkspaceService, + getLiveSessionById, normalizeSessionMeta, reduceContextTranscript, toProtocolMessage, type ContextMessage, + type ISessionScopeHandle, type Scope, type SessionMeta, } from '@moonshot-ai/agent-core-v2'; @@ -115,7 +116,7 @@ export class SnapshotReader implements ISnapshotReader { return toProtocolMessage(sid, index, msg, located.meta.createdAt, createdAtMs); }); - const live = core.accessor.get(ISessionLifecycleService).get(sid); + const live = getLiveSessionById(core.accessor, sid); const session = toWireSession( { ...located.meta, workspaceId: located.workspaceId }, located.cwd, @@ -227,7 +228,7 @@ export class SnapshotReader implements ISnapshotReader { private attachCurrentPromptId( sid: string, - live: ReturnType, + live: ISessionScopeHandle | undefined, inFlightTurn: InFlightTurn | null, ): InFlightTurn | null { if (inFlightTurn === null || live === undefined) return inFlightTurn; @@ -245,7 +246,7 @@ export class SnapshotReader implements ISnapshotReader { private readPending( sid: string, - live: ReturnType, + live: ISessionScopeHandle | undefined, ): { approvals: ReturnType[]; questions: ReturnType[] } { if (live === undefined) return { approvals: [], questions: [] }; const interaction = live.accessor.get(ISessionInteractionService); diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index c91199028..a7165294a 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -47,9 +47,10 @@ import { readFile } from 'node:fs/promises'; import { IAgentLifecycleService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, IAgentLoopService, + followWorkspaceHandlers, + getLiveSessionById, reduceContextTranscript, type IDisposable, type Scope, @@ -134,10 +135,19 @@ export class TranscriptService { constructor(private readonly deps: TranscriptServiceDeps) { // Live entries must not outlive their session: once it closes or archives, - // reads should fall through to the cold rebuild from disk. - const lifecycle = deps.core.accessor.get(ISessionLifecycleService); - lifecycle.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); - lifecycle.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); + // reads should fall through to the cold rebuild from disk. Close/archive + // events are per-handler (Workspace scope), so follow every handler — + // present and future — through the App-scope registry. + followWorkspaceHandlers(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); + const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); } /** @@ -147,7 +157,7 @@ export class TranscriptService { forSessionLive(sessionId: string): TranscriptStore | undefined { const existing = this.live.get(sessionId); if (existing !== undefined) { - if (this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { return existing.store; } // Stale entry for a session already closed/archived (the drop event may @@ -155,7 +165,7 @@ export class TranscriptService { this.dropSession(sessionId); return undefined; } - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); if (session === undefined) return undefined; const store = new TranscriptStore(sessionId); let binding: TranscriptBinding; @@ -234,7 +244,7 @@ export class TranscriptService { // reads (and agent pickers) see the complete historical roster — // including subagents not materialized in this process. try { - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); const meta = await session?.accessor.get(ISessionMetadata).read(); for (const [agentId, agentMeta] of Object.entries(meta?.agents ?? {})) { store.describeAgent(descriptorFromMeta(agentId, agentMeta)); @@ -444,7 +454,7 @@ export class TranscriptService { transcript: AgentTranscript, snapshot: AgentTranscriptSnapshot, ): TranscriptOperation | undefined { - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); const agent = session?.accessor.get(IAgentLifecycleService).get(agentId); const status = agent?.accessor.get(IAgentLoopService).status(); if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined; diff --git a/packages/kap-server/src/transport/channel.ts b/packages/kap-server/src/transport/channel.ts index 5d98ed3f7..1c1e2c632 100644 --- a/packages/kap-server/src/transport/channel.ts +++ b/packages/kap-server/src/transport/channel.ts @@ -8,7 +8,7 @@ */ /** Which scope a route resolves before dispatching. */ -export type ScopeKind = 'core' | 'session' | 'agent'; +export type ScopeKind = 'core' | 'workspace' | 'session' | 'agent'; /** The client-facing channel contract (request/response + future events). */ export interface IChannel { diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 0b874c8bd..8bda0e997 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -39,7 +39,7 @@ export interface ChannelDescriptor { * Registration scope — the minimal scope at which the channel resolves. * Derived from the scoped DI registry. */ - readonly scope: 'app' | 'session' | 'agent'; + readonly scope: 'app' | 'workspace' | 'session' | 'agent'; /** Domain tag recorded at `registerScopedService`. */ readonly domain: string; /** Public prototype members, sorted — events are instance properties and never appear. */ @@ -48,6 +48,7 @@ export interface ChannelDescriptor { const SCOPE_NAME: Record = { [LifecycleScope.App]: 'app', + [LifecycleScope.Workspace]: 'workspace', [LifecycleScope.Session]: 'session', [LifecycleScope.Agent]: 'agent', }; @@ -64,7 +65,12 @@ let serviceNameIndex: Map> | undefined; function scopedServiceNameIndex(): Map> { serviceNameIndex ??= (() => { const map = new Map>(); - for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { + for (const scope of [ + LifecycleScope.App, + LifecycleScope.Workspace, + LifecycleScope.Session, + LifecycleScope.Agent, + ]) { for (const entry of getScopedServiceDescriptors(scope)) { const name = entry.id.toString(); if (!map.has(name)) map.set(name, entry.id); @@ -138,7 +144,12 @@ function describeMethods( */ export function describeAllChannels(): readonly ChannelDescriptor[] { const byName = new Map(); - for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { + for (const scope of [ + LifecycleScope.App, + LifecycleScope.Workspace, + LifecycleScope.Session, + LifecycleScope.Agent, + ]) { for (const entry of getScopedServiceDescriptors(scope)) { const name = entry.id.toString(); if (!byName.has(name)) byName.set(name, entry); diff --git a/packages/kap-server/src/transport/dispatcher.ts b/packages/kap-server/src/transport/dispatcher.ts index 17a8cf4e2..e3b75dba4 100644 --- a/packages/kap-server/src/transport/dispatcher.ts +++ b/packages/kap-server/src/transport/dispatcher.ts @@ -9,8 +9,9 @@ import { ErrorCodes, IAgentGoalService, IAgentLifecycleService, - ISessionLifecycleService, + IWorkspaceLifecycleService, Error2, + getLiveSessionById, type IScopeHandle, type Scope, type ServiceIdentifier, @@ -44,9 +45,13 @@ export async function resolveScope( switch (scopeKind) { case 'core': return core; + case 'workspace': { + const workspaceId = params['workspace_id'] ?? ''; + return core.accessor.get(IWorkspaceLifecycleService).handlerFor({ workspaceId }); + } case 'session': { const sessionId = params['session_id'] ?? ''; - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); } @@ -55,7 +60,7 @@ export async function resolveScope( case 'agent': { const sessionId = params['session_id'] ?? ''; const agentId = params['agent_id'] ?? ''; - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); } diff --git a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts index 5ff70a8be..81163cb7a 100644 --- a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts +++ b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts @@ -1,12 +1,13 @@ /** * Shared Service-dispatcher route registration. * - * Mounts the reflection dispatcher under `basePath`: three routes mirror the + * Mounts the reflection dispatcher under `basePath`: the routes mirror the * scope tree; all share one handler. `:service` is a decorator id (channel * name) resolved against the scoped DI registry; `:method` is invoked by * reflection. Reads use `GET`, writes use `POST`. * * GET|POST {basePath}/:service/:method + * GET|POST {basePath}/workspace/:workspace_id/:service/:method * GET|POST {basePath}/session/:session_id/:service/:method * GET|POST {basePath}/session/:session_id/agent/:agent_id/:service/:method * @@ -75,6 +76,7 @@ export function registerServiceDispatcherRoutes( const lookup = opts.lookup ?? resolveAnyScopedServiceId; const scopeRoutes: { path: string; scopeKind: ScopeKind }[] = [ { path: `${basePath}/:service/:method`, scopeKind: 'core' }, + { path: `${basePath}/workspace/:workspace_id/:service/:method`, scopeKind: 'workspace' }, { path: `${basePath}/session/:session_id/:service/:method`, scopeKind: 'session' }, { path: `${basePath}/session/:session_id/agent/:agent_id/:service/:method`, diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 7a0d10090..6e2fc258d 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -1,9 +1,9 @@ /** * `FsWatchBridge` — volatile `/api/v1/ws` delivery for filesystem changes. * - * Turns the core `ISessionFsWatchService.onDidChangeFiles` feed into - * `event.fs.changed` frames on the v1 WebSocket, byte-compatible with the v1 - * server (`packages/server/.../fsWatcherService.ts`): + * Turns the core `IWorkspaceFsWatchService` feed into `event.fs.changed` + * frames on the v1 WebSocket, byte-compatible with the v1 server + * (`packages/server/.../fsWatcherService.ts`): * * client → `{type:'watch_fs_add', id, payload:{session_id, paths}}` * client → `{type:'watch_fs_remove', id, payload:{session_id, paths}}` @@ -18,9 +18,12 @@ * to the socket — they never enter the broadcaster / journal (fs changes are * volatile: on overflow the client sees `truncated` and re-syncs). * - * The core `ISessionFsWatchService` keeps a single subscription set per - * session; the bridge drives it with the **union** of every connection's - * paths for that session, then re-filters per connection on the way out. + * The core watch service is Workspace-scoped: one os watcher per handler, + * shared by every session of the workspace. The bridge holds ONE + * `IWorkspaceFsWatchSubscription` per session (driven with the union of every + * connection's paths for that session) and re-filters per connection on the + * way out — two sessions of one workspace fan out from the same handler + * watch instead of hanging a second os watcher. */ import { isAbsolute, relative, sep } from 'node:path'; @@ -28,12 +31,16 @@ import { isAbsolute, relative, sep } from 'node:path'; import { type IDisposable, type ISessionScopeHandle, - ISessionFsWatchService, - ISessionLifecycleService, + IWorkspaceFsWatchService, ISessionWorkspaceContext, + getLiveSessionById, type Scope, } from '@moonshot-ai/agent-core-v2'; -import type { FsChangeEntry, FsChangeEvent } from '@moonshot-ai/agent-core-v2/session/sessionFs/fsWatch'; +import type { + FsChangeEntry, + FsChangeEvent, + IWorkspaceFsWatchSubscription, +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fsWatch'; import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; @@ -75,7 +82,7 @@ interface ConnEntry { interface SessionWatch { readonly id: string; readonly session: ISessionScopeHandle; - readonly fsWatch: ISessionFsWatchService; + readonly fsWatch: IWorkspaceFsWatchSubscription; readonly workspace: ISessionWorkspaceContext; readonly conns: Map; union: Set; @@ -176,12 +183,14 @@ export class FsWatchBridge { private resolveSession(sessionId: string): SessionWatch | undefined { const existing = this.bySession.get(sessionId); if (existing !== undefined) return existing; - const session = this.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.core.accessor, sessionId); if (session === undefined) return undefined; const sw: SessionWatch = { id: sessionId, session, - fsWatch: session.accessor.get(ISessionFsWatchService), + // One subscription per session, held on the handler-shared Workspace + // watch service (resolved through the session's parent scope). + fsWatch: session.accessor.get(IWorkspaceFsWatchService).subscribe(), workspace: session.accessor.get(ISessionWorkspaceContext), conns: new Map(), union: new Set(), @@ -208,6 +217,7 @@ export class FsWatchBridge { sw.sub?.dispose(); sw.sub = undefined; sw.fsWatch.setWatchedPaths([]); + sw.fsWatch.dispose(); this.bySession.delete(sw.id); } diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index c1d6ebe8c..e49fca40f 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -70,8 +70,8 @@ import { ISessionActivityView, ISessionInteractionService, ISessionIndex, - ISessionLifecycleService, MAIN_AGENT_ID, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import type { SessionCreatedEvent, SessionMetaUpdatedEvent, Event } from './events'; import { isVolatileEventType } from './events'; @@ -754,7 +754,7 @@ export class SessionEventBroadcaster { private async createSessionState(sessionId: string): Promise { if (this.closed) return undefined; - const session = this.opts.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.opts.core.accessor, sessionId); if (session === undefined) return undefined; const journal = await SessionEventJournal.open( diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index df9032086..045790205 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -72,6 +72,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", ], + [ + "GET", + "/api/v1/debug/workspace/{workspace_id}/{service}/{method}", + ], [ "GET", "/api/v1/files/{file_id}", @@ -236,6 +240,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/workspaces/{workspace_id}/skills", ], + [ + "GET", + "/api/v1/workspaces/{workspace_id}/trust", + ], [ "GET", "/asyncapi.json", @@ -264,6 +272,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", ], + [ + "POST", + "/api/v1/debug/workspace/{workspace_id}/{service}/{method}", + ], [ "POST", "/api/v1/files", @@ -384,6 +396,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces", ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/trust", + ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/untrust", + ], [ "PUT", "/api/v1/providers/{provider_id}", diff --git a/packages/kap-server/test/approvals.test.ts b/packages/kap-server/test/approvals.test.ts index 8622c9bb9..2385af446 100644 --- a/packages/kap-server/test/approvals.test.ts +++ b/packages/kap-server/test/approvals.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ISessionApprovalService, ISessionLifecycleService } from '@moonshot-ai/agent-core-v2'; +import { ISessionApprovalService, getLiveSessionById } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -99,7 +99,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { /** Park an approval in-process so the REST route has something to list/resolve. */ function enqueueApproval(sessionId: string, toolCallId: string): string { - const handle = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(server!.core.accessor, sessionId); expect(handle).toBeDefined(); const parked = handle!.accessor.get(ISessionApprovalService).enqueue({ toolCallId, diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts index 0e7102e96..0a0a2bb2f 100644 --- a/packages/kap-server/test/messages.test.ts +++ b/packages/kap-server/test/messages.test.ts @@ -6,7 +6,7 @@ import { IAgentContextMemoryService, IAgentLifecycleService, IWireService, - ISessionLifecycleService, + getLiveSessionById, IModelCatalog, type ContextMessage, type ScopeSeed, @@ -127,7 +127,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { sessionId: string, messages: readonly ContextMessage[], ): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); let agent = session.accessor.get(IAgentLifecycleService).get('main'); if (agent === undefined) { @@ -302,7 +302,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { // on the same home so the session is genuinely cold on the read path. it('reads the persisted full transcript for a cold session', async () => { const id = await createSession(); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const agent = await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); const ctx = agent.accessor.get(IAgentContextMemoryService); diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 06cae83d4..859c66148 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -8,7 +8,8 @@ import { IAgentLifecycleService, IAgentProfileService, IAgentToolPolicyService, - ISessionLifecycleService, + closeSessionById, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -185,7 +186,7 @@ describe('server-v2 /api/v1 prompts', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so the prompt route resolves. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } @@ -216,7 +217,7 @@ describe('server-v2 /api/v1 prompts', () => { it('rejects a stale file reference without creating the agent or mutating the model', async () => { const id = await createSession(home as string); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { model: 'stub', @@ -233,7 +234,7 @@ describe('server-v2 /api/v1 prompts', () => { it('rejects a mis-kinded file reference without creating the agent', async () => { const id = await createSession(home as string); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); // A real upload, but referenced with the wrong media kind. const form = new FormData(); @@ -640,8 +641,8 @@ describe('server-v2 /api/v1 prompts', () => { // Drop the in-memory handle so the session only exists on disk / in the // index — the state a session is in after a server restart. The route must // cold-resume it rather than report 40401. - await server!.core.accessor.get(ISessionLifecycleService).close(id); - expect(server!.core.accessor.get(ISessionLifecycleService).get(id)).toBeUndefined(); + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>( 'GET', @@ -657,7 +658,7 @@ describe('server-v2 /api/v1 prompts', () => { await createMainAgent(id); // Fork the main agent into a side-channel child the way `/btw` does. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const lifecycle = session.accessor.get(IAgentLifecycleService); const child = await lifecycle.fork('main'); @@ -745,7 +746,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const main = session.accessor.get(IAgentLifecycleService).get('main'); expect(main?.accessor.get(IAgentProfileService).data().profileName).toBe('route-reviewer'); @@ -791,7 +792,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const main = session.accessor.get(IAgentLifecycleService).get('main'); const profile = main?.accessor.get(IAgentProfileService); @@ -812,7 +813,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const toolPolicy = session.accessor.get(IAgentLifecycleService).get('main')?.accessor .get(IAgentToolPolicyService); @@ -848,7 +849,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const child = await session.accessor.get(IAgentLifecycleService).create({ binding: { @@ -887,15 +888,15 @@ describe('server-v2 /api/v1 prompts', () => { expect(submitted.body.code).toBe(0); // Drop the live handle; the next submit cold-resumes the session from disk. - await server!.core.accessor.get(ISessionLifecycleService).close(id); - expect(server!.core.accessor.get(ISessionLifecycleService).get(id)).toBeUndefined(); + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); const again = await call('POST', `/api/v1/sessions/${id}/prompts`, { content: [{ type: 'text', text: 'again' }], }); expect(again.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const toolPolicy = session.accessor.get(IAgentLifecycleService).get('main')?.accessor .get(IAgentToolPolicyService); diff --git a/packages/kap-server/test/questions.test.ts b/packages/kap-server/test/questions.test.ts index 3c28e79cd..a69d6af46 100644 --- a/packages/kap-server/test/questions.test.ts +++ b/packages/kap-server/test/questions.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { ISessionQuestionService, - ISessionLifecycleService, + getLiveSessionById, type QuestionRequest, type QuestionResult, } from '@moonshot-ai/agent-core-v2'; @@ -123,7 +123,7 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => { } function questionService(sessionId: string): ISessionQuestionService { - const handle = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(server!.core.accessor, sessionId); expect(handle).toBeDefined(); return handle!.accessor.get(ISessionQuestionService); } diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index aa6a2f8a3..be903e643 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -12,9 +12,10 @@ import { IEventService, IPluginService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, + IWorkspaceHandlerService, IWorkspaceService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -60,12 +61,14 @@ interface GoalToolResultWire { // name — exactly how the typed client composes URLs, so the test never hardcodes // a channel name that could drift from the token. function rpc( - scope: 'core' | 'session' | 'agent', + scope: 'core' | 'workspace' | 'session' | 'agent', service: ServiceIdentifier, method: string, - ids: { sid?: string; aid?: string } = {}, + ids: { wid?: string; sid?: string; aid?: string } = {}, ): string { if (scope === 'core') return `/api/v1/debug/${String(service)}/${method}`; + if (scope === 'workspace') + return `/api/v1/debug/workspace/${ids.wid}/${String(service)}/${method}`; if (scope === 'session') return `/api/v1/debug/session/${ids.sid}/${String(service)}/${method}`; return `/api/v1/debug/session/${ids.sid}/agent/${ids.aid}/${String(service)}/${method}`; } @@ -132,13 +135,13 @@ describe('server-v2 /api/v1/debug RPC', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so the agent-scope dispatch resolves. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } async function createSubagent(sessionId: string, agentId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId }); } @@ -290,8 +293,16 @@ describe('server-v2 /api/v1/debug RPC', () => { it('archives a session', async () => { const id = await createSession(home as string); - const { body } = await call('POST', rpc('session', ISessionLifecycleService, 'archive', { sid: id }), id); + const workspaceId = (await server!.core.accessor.get(ISessionIndex).get(id))!.workspaceId; + const { body } = await call( + 'POST', + rpc('workspace', IWorkspaceHandlerService, 'archive', { wid: workspaceId }), + id, + ); expect(body.code).toBe(0); + // archive is a method on the handler's session lifecycle service; its + // single argument is the session id. + expect(body.data).toBeNull(); }); // --- Agent scope ---------------------------------------------------------- diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index bc587b27e..6640da340 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -35,7 +35,7 @@ function summary(id: string, title: string, updatedAt = T1): SessionSummary { function makeBootstrap(home: string): IBootstrapService { return { homeDir: home, - sessionDir: (ws: string, sid: string) => join(home, 'sessions', ws, sid), + scope: (name: string) => name, } as unknown as IBootstrapService; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 28c25fc76..83f8fa5d4 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -16,8 +16,10 @@ import { IEventBus, ISessionIndex, ISessionInteractionService, - ISessionLifecycleService, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, + LifecycleScope, SessionInteractionService, StateRegistry, type DomainEvent, @@ -1471,10 +1473,11 @@ describe('AgentTranscriptProjector', () => { core: { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), + handlers: { list: () => [] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; @@ -1569,10 +1572,11 @@ describe('AgentTranscriptProjector', () => { core: { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), + handlers: { list: () => [] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; @@ -1881,14 +1885,27 @@ describe('bindSessionTranscript', () => { } function fakeCoreWithAgents(interactions: SessionInteractionService, agents: FakeAgents): Scope { + const handlerService = { + onDidCloseSession: () => ({ dispose: () => undefined }), + onDidArchiveSession: () => ({ dispose: () => undefined }), + get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), + }; + const handler = { + id: 'ws', + kind: LifecycleScope.Workspace, + accessor: { + get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + }, + dispose: () => undefined, + }; return { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), - get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), + handlers: { list: () => [handler] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 8c8c76466..92b76b589 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -18,6 +18,7 @@ import type { import { ContextSizeModel, IAgentActivityView, + LifecycleScope, IAgentContextSizeService, IAgentLifecycleService, IAgentProfileService, @@ -27,9 +28,10 @@ import { IModelCatalog, ISessionActivityView, ISessionInteractionService, - ISessionLifecycleService, IWireService, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, MAIN_AGENT_ID, SECONDARY_DERIVED_MODEL_ID, SessionInteractionService, @@ -112,7 +114,7 @@ class FakeEventBus { } class FakeAgentHandle { - readonly kind = 2; + readonly kind = LifecycleScope.Agent; readonly bus = new FakeAgentBus(); readonly accessor; private readonly services = new Map(); @@ -350,29 +352,43 @@ function makeCore( eventBus = new FakeEventBus(), metaAgents: Record = {}, ): Scope { + const sessionFor = (sid: string) => { + const lifecycle = sessions.get(sid); + if (lifecycle === undefined) return undefined; + const sessionAccessor = { + get: (t: unknown) => { + if (t === IAgentLifecycleService) return lifecycle; + if (t === ISessionInteractionService) return lifecycle.interactions; + if (t === ISessionActivityView) return lifecycle.workView; + // Minimal metadata read for the transcript binding's descriptor pass. + if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; + return undefined; + }, + }; + return { id: sid, kind: LifecycleScope.Session, accessor: sessionAccessor, dispose: () => {} }; + }; + const handlerService = { + // Inert lifecycle events (TranscriptService subscribes on construction). + onDidCloseSession: () => ({ dispose: () => {} }), + onDidArchiveSession: () => ({ dispose: () => {} }), + get: sessionFor, + }; + const handler = { + id: 'wd', + kind: LifecycleScope.Workspace, + accessor: { + get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + }, + dispose: () => {}, + }; const accessor = { get(token: unknown): unknown { if (token === IEventService) return eventBus; - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - // Inert lifecycle events (TranscriptService subscribes on construction). - onDidCloseSession: () => ({ dispose: () => {} }), - onDidArchiveSession: () => ({ dispose: () => {} }), - get: (sid: string) => { - const lifecycle = sessions.get(sid); - if (lifecycle === undefined) return undefined; - const sessionAccessor = { - get: (t: unknown) => { - if (t === IAgentLifecycleService) return lifecycle; - if (t === ISessionInteractionService) return lifecycle.interactions; - if (t === ISessionActivityView) return lifecycle.workView; - // Minimal metadata read for the transcript binding's descriptor pass. - if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; - return undefined; - }, - }; - return { id: sid, kind: 1, accessor: sessionAccessor, dispose: () => {} }; - }, + handlers: { list: () => [handler] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => {} }), }; } return undefined; diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index e7c5a1f7a..c8049bcd4 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -22,8 +22,10 @@ import { IAgentLifecycleService, IEventBus, IEventService, - ISessionLifecycleService, MAIN_AGENT_ID, + closeSessionById, + getLiveSessionById, + sessionDirOf, type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; @@ -209,9 +211,11 @@ describe('server-v2 /api/v1/sessions', () => { metadata: { cwd: home as string }, }); const id = created.body.data.id; - const sessionDir = (server as RunningServer).core.accessor - .get(IBootstrapService) - .sessionDir(created.body.data.workspace_id, id); + const sessionDir = sessionDirOf( + (server as RunningServer).core.accessor.get(IBootstrapService).homeDir, + `sessions/${created.body.data.workspace_id}`, + id, + ); await writeFile(join(sessionDir, 'cancel-test.bin'), randomBytes(8 * 1024 * 1024)); const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { @@ -289,9 +293,7 @@ describe('server-v2 /api/v1/sessions', () => { await postJson(`/api/v1/sessions/${id}/profile`, { agent_config: { goal_objective: 'finish the migration' }, }); - const session = (server as RunningServer).core.accessor - .get(ISessionLifecycleService) - .get(id); + const session = getLiveSessionById((server as RunningServer).core.accessor, id); if (session === undefined) throw new Error('expected a live session'); const agent = session.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); if (agent === undefined) throw new Error('expected a live main agent'); @@ -664,7 +666,7 @@ describe('server-v2 /api/v1/sessions', () => { // only) — the state right after opening a session in the web UI before any // prompt has been sent. Before the fix, `:undo` resolved the main agent via // `lifecycle.get` (memory only) and reported 40401 "session does not exist". - await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); + await closeSessionById((server as RunningServer).core.accessor, id); const res = await postJson<{ messages: unknown }>(`/api/v1/sessions/${id}:undo`, { count: 1 }); // Cold-loaded successfully: the empty history yields "nothing to undo" @@ -680,9 +682,7 @@ describe('server-v2 /api/v1/sessions', () => { const created = await postJson('/api/v1/sessions', { metadata: { cwd: home as string }, }); - const session = (server as RunningServer).core.accessor - .get(ISessionLifecycleService) - .get(created.body.data.id); + const session = getLiveSessionById((server as RunningServer).core.accessor, created.body.data.id); if (session === undefined) throw new Error('expected live session'); const agent = await session.accessor .get(IAgentLifecycleService) diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 7e7d7e227..01b40e47c 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -27,7 +27,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, - ISessionLifecycleService, + getLiveSessionById, ISkillCatalogRuntimeOptions, } from '@moonshot-ai/agent-core-v2'; import { @@ -108,7 +108,7 @@ describe('server-v2 /api/v1 skills', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so skill activation can start a turn. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); const agents = session.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) await agents.create({ agentId: 'main' }); diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index 9ed27f2da..5a268fda6 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -16,9 +16,13 @@ import { ILogService, ISessionInteractionService, ISessionContext, - ISessionLifecycleService, + ISessionIndex, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, IWorkspaceService, + getLiveSessionById, + resumeSessionById, } from '@moonshot-ai/agent-core-v2'; import { sessionSnapshotResponseSchema } from '../src/protocol/rest-snapshot'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -75,11 +79,32 @@ describe('server-v2 snapshot route enrichment', () => { [ISessionInteractionService, { listPending: () => [] }], ]), }; + const handler = { + accessor: fakeAccessor([ + [ + IWorkspaceHandlerService, + { resume: async () => session, get: () => undefined }, + ], + ]), + }; const core = { accessor: fakeAccessor([ [ - ISessionLifecycleService, - { resume: async () => session, get: () => undefined }, + ISessionIndex, + { + get: async () => ({ + id: sessionId, + workspaceId, + cwd: '/workspace', + createdAt: now, + updatedAt: now, + archived: false, + }), + }, + ], + [ + IWorkspaceLifecycleService, + { handlerFor: async () => handler, handlers: { list: () => [] } }, ], [IWorkspaceService, { get: async () => ({ root: '/workspace' }) }], ]), @@ -282,13 +307,13 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { } async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agents = session!.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) await agents.create({ agentId: 'main' }); } function emit(sessionId: string, event: DomainEvent): void { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const main = session!.accessor.get(IAgentLifecycleService).get('main'); main!.accessor.get(IEventBus).publish(event); } @@ -355,7 +380,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { base = `http://127.0.0.1:${server.port}`; // Guard: nothing is live in the new process — the session is cold. - expect(server!.core.accessor.get(ISessionLifecycleService).get(sid)).toBeUndefined(); + expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); const snap = await snapshot(sid); expect(snap.session.id).toBe(sid); @@ -367,7 +392,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // transcript while the scope stays un-materialized. it('auto reader returns messages read directly from wire.jsonl for a cold session', async () => { const sid = await createSession(); - const live = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const live = getLiveSessionById(server!.core.accessor, sid); if (live === undefined) throw new Error(`session ${sid} not found`); const metaScope = live.accessor.get(ISessionContext).metaScope; @@ -400,7 +425,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { base = `http://127.0.0.1:${server.port}`; // Guard: still cold — the auto reader must serve from disk, not resume. - expect(server!.core.accessor.get(ISessionLifecycleService).get(sid)).toBeUndefined(); + expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); const snap = await snapshot(sid); expect(snap.session.id).toBe(sid); @@ -420,7 +445,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // (no reliance on wire-restore timing). it('serves a v1-layout session (ISO timestamps, no id field) without crashing', async () => { const sid = await createSession(); - const session = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(server!.core.accessor, sid); if (session === undefined) throw new Error(`session ${sid} not found`); const metaScope = session.accessor.get(ISessionContext).metaScope; @@ -445,7 +470,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // Resume the cold session, then seed messages into the live context so the // snapshot projects message timestamps from the normalized numeric base. - const resumed = await server!.core.accessor.get(ISessionLifecycleService).resume(sid); + const resumed = await resumeSessionById(server!.core.accessor, sid); if (resumed === undefined) throw new Error(`session ${sid} failed to resume`); const main = await resumed.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); const context = main.accessor.get(IAgentContextMemoryService); diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index cf2b542a1..7ec1f0c81 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -15,7 +15,7 @@ import { join } from 'node:path'; import { ISessionIndex, - ISessionLifecycleService, + IWorkspaceLifecycleService, IWorkspaceService, type ContextMessage, type SessionSummary, @@ -67,7 +67,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise index.get(sid) }], [IWorkspaceService, { get: async (ws: string) => workspaces.get(ws) }], // Cold by default — no live handle. - [ISessionLifecycleService, { get: () => undefined }], + [IWorkspaceLifecycleService, { handlers: { list: () => [] } }], ]), }; const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null }; diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index 82fa8e283..8a56fc007 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, IAgentTaskService, - ISessionLifecycleService, + getLiveSessionById, IModelCatalog, type AgentTask, } from '@moonshot-ai/agent-core-v2'; @@ -126,7 +126,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { // (server-v2 gap G10); create it here, then register fake tasks // directly into its IAgentTaskService to bypass the tool loop. async function mainAgentTasks(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); const agent = session.accessor.get(IAgentLifecycleService).get('main') ?? diff --git a/packages/kap-server/test/tools.test.ts b/packages/kap-server/test/tools.test.ts index 26526eae1..35889f640 100644 --- a/packages/kap-server/test/tools.test.ts +++ b/packages/kap-server/test/tools.test.ts @@ -20,7 +20,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, IAgentToolRegistryService, - ISessionLifecycleService, + getLiveSessionById, ISessionToolPolicy, IModelCatalog, type ExecutableTool, @@ -134,7 +134,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so IToolRegistry / IMcpService resolve. async function ensureMainAgent(sessionId: string) { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); let agent = session.accessor.get(IAgentLifecycleService).get('main'); agent ??= await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); @@ -214,7 +214,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { // Set the session-scope denylist directly: this harness's model catalog is // a throwing stub, so the REST prompt path (which requires a bound profile) // is unavailable here. The composed gate read by the route is the same. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); await session.accessor.get(ISessionToolPolicy).setDisabledTools(['Bash']); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index cbeecca31..2cec22368 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -18,8 +18,10 @@ import { IWireService, IEventBus, ISessionInteractionService, - ISessionLifecycleService, ISessionQuestionService, + closeSessionById, + getLiveSessionById, + resumeSessionById, IModelCatalog, type ContextMessage, type DomainEvent, @@ -198,7 +200,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { /** Ensure the main agent exists (server-v2 does not create it with the session). */ async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); if (session.accessor.get(IAgentLifecycleService).get('main') === undefined) { await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); @@ -206,7 +208,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { } function mainAgentBus(sessionId: string): IEventBus { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agent = session!.accessor.get(IAgentLifecycleService).get('main'); return agent!.accessor.get(IEventBus); } @@ -215,7 +217,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { sessionId: string, messages: readonly ContextMessage[], ): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agent = session!.accessor.get(IAgentLifecycleService).get('main'); agent!.accessor.get(IAgentContextMemoryService).append(...messages); await agent!.accessor.get(IWireService).flush(); @@ -299,7 +301,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { }), ); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-1', @@ -436,7 +438,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { await server!.close(); server = undefined; await boot(); - await server!.core.accessor.get(ISessionLifecycleService).resume(id); + await resumeSessionById(server!.core.accessor, id); const { body } = await getJson( `/api/v1/sessions/${id}/transcript?agent_id=main`, @@ -462,7 +464,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('rebuilds a subagent for a cold session from its own wire records', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -495,7 +497,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('backfills an unmaterialized subagent for a resumed live session', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -511,11 +513,9 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { await server!.close(); server = undefined; await boot(); - await server!.core.accessor.get(ISessionLifecycleService).resume(id); + await resumeSessionById(server!.core.accessor, id); expect( - server!.core.accessor - .get(ISessionLifecycleService) - .get(id)! + getLiveSessionById(server!.core.accessor, id)! .accessor.get(IAgentLifecycleService) .get('sub-1'), ).toBeUndefined(); @@ -535,7 +535,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('keeps the metadata-seeded subagent descriptor after an on-demand backfill', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor .get(IAgentLifecycleService) .create({ agentId: 'sub-1', labels: { parentAgentId: 'main' } }); @@ -572,7 +572,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { ]); // The approval is already pending when the transcript binds. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); session!.accessor.get(ISessionInteractionService).enqueue({ id: 'apr-1', kind: 'approval', @@ -620,7 +620,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('seeds a subagent pending question only after its own backfill', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -710,7 +710,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - await server!.core.accessor.get(ISessionLifecycleService).close(id); + await closeSessionById(server!.core.accessor, id); const { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); expect(body.code).toBe(0); @@ -782,7 +782,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('routes a subagent question to the subagent transcript, not main', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); // Bind the transcript (main + any agent appearing later). @@ -980,7 +980,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('serves per-agent user messages for every rostered agent when agent_id is omitted (live)', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -1040,7 +1040,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { toolCalls: [], } as ContextMessage, ]); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -1120,7 +1120,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { path: '/tmp/plans/foo.md', options: [{ label: 'Approach A', description: 'fast' }], }; - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-plan', @@ -1253,7 +1253,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { // A Revise outcome: the tool result carries no plan content — only the // persisted interaction does. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-plan', @@ -1366,7 +1366,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { }), ); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-final', diff --git a/packages/kap-server/test/workspaceLayout.test.ts b/packages/kap-server/test/workspaceLayout.test.ts new file mode 100644 index 000000000..6041e0991 --- /dev/null +++ b/packages/kap-server/test/workspaceLayout.test.ts @@ -0,0 +1,156 @@ +/** + * Compatibility专项 — the local/local on-disk layout after the Workspace-domain + * refactor is byte-identical to the pre-refactor one. + * + * Creates a session through the full server stack (workspace handler → + * session scope → main agent) and asserts the persisted artifacts a v1 + * reader depends on: `workspaces.json` (original schema), + * `session_index.jsonl` (`{sessionId, sessionDir, workDir}` with + * `sessionDir = /sessions/{wd_id}/{session_id}`), + * `sessions/{wd_id}/{sid}/state.json`, and + * `sessions/{wd_id}/{sid}/agents/main/wire.jsonl` (metadata envelope first, + * `agents.main.homedir` written with the original value). Also proves the + * disk-reading `ISnapshotReader` serves the layout end-to-end. + * Wiring: real kap-server on a temp home. + * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/workspaceLayout.test.ts`. + */ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + IAgentLifecycleService, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; + +import { type RunningServer, startServer } from '../src/start'; +import { authHeaders } from './helpers/auth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('local/local on-disk layout (byte compatibility)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let workDir: string | undefined; + let base: string; + const homes: string[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-layout-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-layout-work-')); + homes.push(home, workDir); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + hostIdentity: TEST_HOST_IDENTITY, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + await Promise.all(homes.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function postJson(path: string, body: unknown): Promise> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeaders(server!) }, + body: JSON.stringify(body), + }); + return (await res.json()) as Envelope; + } + + it('persists the pre-refactor layout byte-for-byte and serves it through the snapshot reader', async () => { + const created = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(created.code).toBe(0); + const sessionId = created.data.id; + const workspaceId = created.data.workspace_id; + const sessionDir = join(home!, 'sessions', workspaceId, sessionId); + + // workspaces.json — original v1 schema, one record keyed by workspace id. + const workspacesFile = JSON.parse(await readFile(join(home!, 'workspaces.json'), 'utf8')) as { + version: number; + workspaces: Record< + string, + { root: string; name: string; created_at: string; last_opened_at: string } + >; + deleted_workspace_ids: string[]; + }; + expect(workspacesFile.version).toBe(1); + expect(Object.keys(workspacesFile.workspaces)).toEqual([workspaceId]); + expect(workspacesFile.workspaces[workspaceId]).toMatchObject({ root: workDir }); + expect(workspacesFile.deleted_workspace_ids).toEqual([]); + + // session_index.jsonl — one line per session, `{sessionId, sessionDir, + // workDir}` with the v1 bucket address. + const indexLines = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string; sessionDir: string; workDir: string }); + expect(indexLines).toEqual([{ sessionId, sessionDir, workDir }]); + + // state.json — the session metadata document at the v1 path. + const metaRaw = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + id: string; + }; + expect(metaRaw.id).toBe(sessionId); + + // Materialize the main agent in-process; its wire log lands at the v1 + // path with the metadata envelope first and the original homedir value. + const session = getLiveSessionById(server!.core.accessor, sessionId); + expect(session).toBeDefined(); + await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await expect(stat(wirePath)).resolves.toBeDefined(); + const firstWireLine = (await readFile(wirePath, 'utf8')).split('\n')[0]!; + expect((JSON.parse(firstWireLine) as { type: string }).type).toBe('metadata'); + const metaWithAgent = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + agents: Record; + }; + expect(metaWithAgent.agents['main']?.homedir).toBe(join(sessionDir, 'agents', 'main')); + + // The snapshot reader serves the layout straight from disk (auto mode). + const snapshot = await fetch(`${base}/api/v1/sessions/${sessionId}/snapshot`, { + headers: authHeaders(server!), + }); + const snapshotBody = (await snapshot.json()) as Envelope<{ session: { id: string } }>; + expect(snapshotBody.code).toBe(0); + expect(snapshotBody.data.session.id).toBe(sessionId); + + // A second session in the same workspace lands in the SAME bucket and the + // catalog still holds exactly one record (handler-level touch, no schema + // or duplication drift). + const second = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(second.code).toBe(0); + expect(second.data.workspace_id).toBe(workspaceId); + const workspacesAfter = JSON.parse( + await readFile(join(home!, 'workspaces.json'), 'utf8'), + ) as { workspaces: Record }; + expect(Object.keys(workspacesAfter.workspaces)).toEqual([workspaceId]); + const indexAfter = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string }); + expect(indexAfter.map((entry) => entry.sessionId)).toEqual([sessionId, second.data.id]); + }); +}); diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts index f2116d42e..20f3aea39 100644 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -12,7 +12,7 @@ import { type DomainEvent, IEventBus, IAgentLifecycleService, - ISessionLifecycleService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; @@ -145,7 +145,7 @@ describe('server-v2 /api/v1/ws resync', () => { } async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) { @@ -158,7 +158,7 @@ describe('server-v2 /api/v1/ws resync', () => { } function emitAgentEvent(sessionId: string, event: DomainEvent): void { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); const main = agents.get('main'); @@ -261,7 +261,7 @@ describe('server-v2 /api/v1/ws resync', () => { await ensureMainAgent(sid); // Add a second agent to the same session so we can distinguish sources. - const session = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(server!.core.accessor, sid); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); const sub = await agents.create({ agentId: 'agent-0' }); diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts index d3b620731..3f288d21c 100644 --- a/packages/klient/src/contract/global/plugins.ts +++ b/packages/klient/src/contract/global/plugins.ts @@ -1,7 +1,7 @@ /** * `pluginService` — plugin management and consumption. Mirrors * `agent-core-v2/app/plugin/plugin.ts` and `agent-core-v2/app/plugin/types.ts`; - * nested `McpServerConfig` mirrors `agent-core-v2/agent/mcp/config-schema.ts`, + * nested `McpServerConfig` mirrors `agent-core-v2/mcpCore/config-schema.ts`, * `HookDefConfig` mirrors `agent-core-v2/agent/externalHooks/configSection.ts`. * `pluginSkillRoots`, `enabledSessionStarts`, `enabledSystemPrompts`, * `enabledMcpServers`, and `enabledHooks` are excluded (not part of the diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 39065bf55..b98836ec9 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -30,7 +30,10 @@ import { sessionsContract } from './global/sessions.js'; import { workspacesContract } from './global/workspaces.js'; import { sessionApprovalContract } from './session/approval.js'; import { sessionInteractionContract } from './session/interaction.js'; -import { sessionLifecycleContract } from './session/lifecycle.js'; +import { + workspaceHandlerContract, + workspaceLifecycleContract, +} from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; @@ -49,8 +52,10 @@ export const globalContract: KlientContract = { pluginService: pluginsContract, hostFolderBrowser: hostFsContract, bootstrapService: envContract, - // session scope (+ the app-registered lifecycle service) - sessionLifecycleService: sessionLifecycleContract, + // workspace scope (+ the app-registered handler registry) + workspaceLifecycleService: workspaceLifecycleContract, + workspaceHandlerService: workspaceHandlerContract, + // session scope sessionMetadata: sessionMetadataContract, sessionInteractionService: sessionInteractionContract, sessionApprovalService: sessionApprovalContract, diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts index d9e673433..2ecb9bdda 100644 --- a/packages/klient/src/contract/mcp.ts +++ b/packages/klient/src/contract/mcp.ts @@ -1,6 +1,6 @@ /** * Shared MCP server wire schema for session creation and plugin manifests. - * Mirrors `agent-core-v2/agent/mcp/config-schema.ts`. + * Mirrors `agent-core-v2/mcpCore/config-schema.ts`. */ import { z } from 'zod'; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 4fd9c3c50..094133031 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -1,22 +1,24 @@ /** - * `sessionLifecycleService` — creates and tracks sessions at the process - * root. Mirrors `agent-core-v2/app/sessionLifecycle/sessionLifecycle.ts`. - * The engine returns `ISessionScopeHandle`s; over JSON only the plain data - * fields survive, so the wire keeps `{ id, kind }` (loose — extra fields may - * appear in-process). + * `workspaceHandlerService` / `workspaceLifecycleService` — session + * lifecycle after the Workspace-domain split. The App-scope + * `workspaceLifecycleService` materializes one handler per workspace + * (`handlerFor`); the Workspace-scope `workspaceHandlerService` owns that + * workspace's sessions (create/resume/close/archive/restore/fork/ + * createChild). Mirrors `agent-core-v2/app/workspaceLifecycle/*` and + * `agent-core-v2/workspace/workspaceHandler/*`. The engine returns scope + * handles; over JSON only the plain data fields survive, so the wire keeps + * `{ id, kind }` (loose — extra fields may appear in-process). */ import { z } from 'zod'; import { maybe, noResult } from '../helpers.js'; -import { mcpServerConfigSchema } from '../mcp.js'; import type { ServiceContract } from '../types.js'; export const createSessionOptionsSchema = z.object({ sessionId: z.string().optional(), workDir: z.string(), additionalDirs: z.array(z.string()).optional(), - mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), }); export const forkSessionOptionsSchema = z.object({ @@ -29,13 +31,23 @@ export const forkSessionOptionsSchema = z.object({ /** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ export const createChildSessionOptionsSchema = forkSessionOptionsSchema; -/** `ISessionScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ +/** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ export const handleWireSchema = z.looseObject({ id: z.string(), kind: z.number(), }); -export const sessionLifecycleContract = { +/** `WorkspaceRef` — a `workspaceId` (optional `root` hint) or a bare `root`. */ +export const workspaceRefSchema = z.union([ + z.object({ workspaceId: z.string(), root: z.string().optional() }), + z.object({ root: z.string() }), +]); + +export const workspaceLifecycleContract = { + handlerFor: { input: z.tuple([workspaceRefSchema]), output: handleWireSchema }, +} satisfies ServiceContract; + +export const workspaceHandlerContract = { create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, close: { input: z.tuple([z.string()]), output: noResult }, diff --git a/packages/klient/src/core/channel.ts b/packages/klient/src/core/channel.ts index b8bec6ac0..181290167 100644 --- a/packages/klient/src/core/channel.ts +++ b/packages/klient/src/core/channel.ts @@ -14,6 +14,7 @@ export interface IDisposable { /** Scope coordinates of a call/subscription. Empty object = core (app) scope. */ export interface ScopeRef { + readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string; } diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index bef77c30d..d21a5dbbe 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -49,7 +49,7 @@ export type Caller = (service: string, method: string, args: unknown[]) => Promi /** Scoped variant — the factory's real signature; global methods bind the core scope. */ export type ScopedCaller = ( - scope: { readonly sessionId?: string; readonly agentId?: string }, + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, service: string, method: string, args: unknown[], @@ -57,7 +57,7 @@ export type ScopedCaller = ( /** Streaming variant of `ScopedCaller` — returns a validated `AsyncIterable`. */ export type ScopedStreamCaller = ( - scope: { readonly sessionId?: string; readonly agentId?: string }, + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, service: string, method: string, args: unknown[], @@ -272,7 +272,12 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr countActive: (workspaceIds) => call('sessionIndex', 'countActive', [workspaceIds]) as Promise, create: async ({ workDir, additionalDirs, title }) => { - const handle = (await scoped({}, 'sessionLifecycleService', 'create', [ + // The workspace handler owns session creation: materialize (or reuse) + // the handler for the root, then create under it. + const handler = (await scoped({}, 'workspaceLifecycleService', 'handlerFor', [ + { root: workDir }, + ])) as { id: string }; + const handle = (await scoped({ workspaceId: handler.id }, 'workspaceHandlerService', 'create', [ { workDir, additionalDirs }, ])) as { id: string }; const scope = { sessionId: handle.id }; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index cce957aa6..9df21846b 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -27,11 +27,14 @@ import type { } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import type { ScopeRef } from '../channel.js'; +import { RPCError } from '../errors.js'; import type { ScopedCaller } from './global.js'; +const NOT_FOUND = 40404; + export type { ScopedCaller } from './global.js'; -/** What `sessionLifecycleService.create/fork/createChild` leaves on the wire. */ +/** What `workspaceHandlerService.create/fork/createChild` leaves on the wire. */ interface HandleWire { readonly id: string; } @@ -86,11 +89,23 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess const scope: ScopeRef = { sessionId }; const read = (): Promise => call(scope, 'sessionMetadata', 'read', []) as Promise; + // Session lifecycle methods live on the session's workspace handler + // (Workspace scope) — the index supplies the handler's workspaceId. + const resolveWorkspaceId = async (): Promise => { + const summary = (await call({}, 'sessionIndex', 'get', [sessionId])) as + | { workspaceId: string } + | undefined; + return summary?.workspaceId; + }; const spawn = async ( method: 'fork' | 'createChild', input: { title?: string; metadata?: Record } = {}, ): Promise => { - const handle = (await call({}, 'sessionLifecycleService', method, [ + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) { + throw new RPCError(NOT_FOUND, `session not found: ${sessionId}`); + } + const handle = (await call({ workspaceId }, 'workspaceHandlerService', method, [ { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, ])) as HandleWire; return call({ sessionId: handle.id }, 'sessionMetadata', 'read', []) as Promise; @@ -127,10 +142,20 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess } return 'idle'; }, - close: () => call({}, 'sessionLifecycleService', 'close', [sessionId]) as Promise, - archive: () => call({}, 'sessionLifecycleService', 'archive', [sessionId]) as Promise, + close: async () => { + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return; + await call({ workspaceId }, 'workspaceHandlerService', 'close', [sessionId]); + }, + archive: async () => { + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return; + await call({ workspaceId }, 'workspaceHandlerService', 'archive', [sessionId]); + }, restore: async () => { - const handle = (await call({}, 'sessionLifecycleService', 'restore', [ + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return false; + const handle = (await call({ workspaceId }, 'workspaceHandlerService', 'restore', [ sessionId, ])) as HandleWire | null; return handle !== null; diff --git a/packages/klient/src/transports/ipc/channel.ts b/packages/klient/src/transports/ipc/channel.ts index 912c2d486..8c1993767 100644 --- a/packages/klient/src/transports/ipc/channel.ts +++ b/packages/klient/src/transports/ipc/channel.ts @@ -45,9 +45,10 @@ interface PendingStream { error(err: Error): void; } -function scopeKindOf(scope: ScopeRef): 'core' | 'session' | 'agent' { +function scopeKindOf(scope: ScopeRef): 'core' | 'workspace' | 'session' | 'agent' { if (scope.agentId !== undefined) return 'agent'; if (scope.sessionId !== undefined) return 'session'; + if (scope.workspaceId !== undefined) return 'workspace'; return 'core'; } @@ -122,6 +123,7 @@ export class IpcChannel implements KlientChannel { // NDJSON is JSON: trailing optional args would cross as `null` and // defeat the host's default parameters — trim them. arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }); @@ -207,6 +209,7 @@ export class IpcChannel implements KlientChannel { service, method, arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }); @@ -261,6 +264,7 @@ export class IpcChannel implements KlientChannel { type: 'listen', id, scope: scopeKindOf(scope), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }; diff --git a/packages/klient/src/transports/ipc/codec.ts b/packages/klient/src/transports/ipc/codec.ts index ccf7be7aa..21f326224 100644 --- a/packages/klient/src/transports/ipc/codec.ts +++ b/packages/klient/src/transports/ipc/codec.ts @@ -13,6 +13,7 @@ export interface IpcFrame { readonly service?: string; readonly method?: string; readonly arg?: unknown; + readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string; readonly event?: string; diff --git a/packages/klient/src/transports/ipc/host.ts b/packages/klient/src/transports/ipc/host.ts index 26898984b..f734af46c 100644 --- a/packages/klient/src/transports/ipc/host.ts +++ b/packages/klient/src/transports/ipc/host.ts @@ -31,7 +31,8 @@ export interface KlientIpcHost { } function scopeRefFromFrame(frame: IpcFrame): ScopeRef { - const scope: { sessionId?: string; agentId?: string } = {}; + const scope: { workspaceId?: string; sessionId?: string; agentId?: string } = {}; + if (typeof frame.workspaceId === 'string') scope.workspaceId = frame.workspaceId; if (typeof frame.sessionId === 'string') scope.sessionId = frame.sessionId; if (typeof frame.agentId === 'string') scope.agentId = frame.agentId; return scope; diff --git a/packages/klient/src/transports/memory/dispatcher.ts b/packages/klient/src/transports/memory/dispatcher.ts index c3553ee4d..11c2c3fb5 100644 --- a/packages/klient/src/transports/memory/dispatcher.ts +++ b/packages/klient/src/transports/memory/dispatcher.ts @@ -3,7 +3,7 @@ * against a live engine scope and mirrors kap-server's dispatcher semantics * (reflection call, non-function members are property reads, `main` agent * auto-materialized via `ensureMainAgent`). Scope routing walks - * `ISessionLifecycleService` / `IAgentLifecycleService` exactly like the + * `IWorkspaceLifecycleService` / `IAgentLifecycleService` exactly like the * server's `resolveScope`. Every argument, result, and event payload passes * through `wireClone` (a JSON round-trip), so consumers observe * byte-identical data no matter whether the call crossed a socket or stayed @@ -14,7 +14,8 @@ */ import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; -import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; +import { getLiveSessionById } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { IAgentLifecycleService } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/mainAgent'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; @@ -51,7 +52,7 @@ export interface MemoryDispatcher { const REQUEST_INVALID = 40001; const NOT_FOUND = 40404; -type ScopeKind = 'core' | 'session' | 'agent'; +type ScopeKind = 'core' | 'workspace' | 'session' | 'agent'; interface ResolvedScope { readonly kind: ScopeKind; @@ -61,8 +62,14 @@ interface ResolvedScope { export function createMemoryDispatcher(root: ScopeLike): MemoryDispatcher { /** Mirrors kap-server's `resolveScope`, incl. main-agent materialization. */ async function resolveScope(scope: ScopeRef): Promise { + if (scope.workspaceId !== undefined) { + const handler = await root.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: scope.workspaceId }); + return { kind: 'workspace', like: handler }; + } if (scope.sessionId === undefined) return { kind: 'core', like: root }; - const session = root.accessor.get(ISessionLifecycleService).get(scope.sessionId); + const session = getLiveSessionById(root.accessor, scope.sessionId); if (session === undefined) { throw new RPCError(NOT_FOUND, `session not found: ${scope.sessionId}`); } diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 354089a17..b06d1c571 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -22,7 +22,8 @@ import { IPluginService } from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; -import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; @@ -50,7 +51,8 @@ export const serviceTokens: Readonly>> pluginService: IPluginService, hostFolderBrowser: IHostFolderBrowser, bootstrapService: IBootstrapService, - sessionLifecycleService: ISessionLifecycleService, + workspaceLifecycleService: IWorkspaceLifecycleService, + workspaceHandlerService: IWorkspaceHandlerService, sessionMetadata: ISessionMetadata, sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 70a6ba3bc..2185a86c5 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -44,7 +44,7 @@ import type { CreateChildSessionOptions, CreateSessionOptions, ForkSessionOptions, -} from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import type { ApprovalRequest, ApprovalResponse, diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts index 256a1b70f..caac024a6 100644 --- a/packages/klient/test/contract.test.ts +++ b/packages/klient/test/contract.test.ts @@ -14,16 +14,6 @@ import { createSessionOptionsSchema } from '../src/contract/session/lifecycle.js type McpTimeoutField = 'startupTimeoutMs' | 'toolTimeoutMs'; const timeoutCases = [ - { - surface: 'session creation', - parse: (field: McpTimeoutField, value: number) => - createSessionOptionsSchema.safeParse({ - workDir: '/tmp/example', - mcpServers: { - example: { transport: 'stdio', command: 'node', [field]: value }, - }, - }), - }, { surface: 'plugin manifests', parse: (field: McpTimeoutField, value: number) => @@ -47,4 +37,15 @@ describe('MCP timeout contract validation', () => { it.each(timeoutCases)('rejects an above-maximum $field for $surface', ({ field, parse }) => { expect(parse(field, 2_147_483_648).success).toBe(false); }); + + it('session creation options carry no caller mcpServers channel', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + example: { transport: 'stdio', command: 'node' }, + }, + }); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual({ workDir: '/tmp/example' }); + }); }); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index eeaa2efb3..8b69c9246 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -72,6 +72,28 @@ export function defineKlientConformance( expect(typeof count).toBe('number'); }); + it('creates a titled session through implicit workspace materialization', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance session', + }); + + try { + expect(created).toMatchObject({ + title: 'conformance session', + cwd: process.cwd(), + archived: false, + }); + expect(created.id.length).toBeGreaterThan(0); + expect(await target.klient.global.sessions.get(created.id)).toMatchObject({ + id: created.id, + title: 'conformance session', + }); + } finally { + await target.klient.session(created.id).close(); + } + }); + it('providers.set/get/delete works and emits kosong.providers.changed', async () => { const events: Array<{ added: readonly string[]; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d1..d4b93dea4 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -30,9 +30,9 @@ * `updateSessionMetadata` / `addAdditionalDir` → the session lifecycle * batch: `klient.global.sessions.list` plus the `klient.session(id)` * metadata mutations where the facade reaches, and the - * `ISessionLifecycleService` / session-scope services through + * `IWorkspaceLifecycleService` / handler chain / session-scope services through * {@link engineAccessor} where it does not (explicit session ids, resume, - * fork ids, workspace commands). The v1 `SessionSummary` / `SessionMeta` + * fork ids, the workspace-level add-dir surface). The v1 `SessionSummary` / `SessionMeta` * shapes are restored by the pure mapping layer in * `src/v2/session-mapper.ts`. `deleteSession` stays `not_implemented` — * the v2 engine has no session-deletion capability anywhere (tracked in @@ -87,9 +87,10 @@ * `IAtomicDocumentStore`, whose on-disk layout * (`/credentials/mcp/-*.json`) matches v1's. * - `listMcpServers` / `getMcpStartupMetrics` / `reconnectMcpServer` → - * `ISessionMcpService.connectionManager()` through the session scope (no - * klient facade exists); the v2 `McpServerEntry` is field-identical with - * v1's `McpServerInfo`. + * the seeded `ISessionMcpHandle.connectionManager` through the session + * scope (no klient facade exists) — one shared manager per workspace + * handler since the workspace-domain resource consolidation; the v2 + * `McpServerEntry` is field-identical with v1's `McpServerInfo`. * - `onEvent` / `receiveEvent` → the base class registries, fed by a * per-live-session wiring (`src/v2/session-wiring.ts`) that subscribes * every live agent's `IEventBus` and translates each `DomainEvent` back @@ -138,14 +139,14 @@ import { type ExperimentalFeatureState, } from '@moonshot-ai/agent-core'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; -import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/agent/mcp/configSection'; -import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/app/mcpConfig/configSection'; +import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { AlreadyAuthorizedError, McpOAuthService, type BeginAuthorizationResult, -} from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/service'; -import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/store'; +} from '@moonshot-ai/agent-core-v2/mcpCore/oauth/service'; +import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/app/mcpConfig/oauthStore'; import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; @@ -178,7 +179,6 @@ import { IHostFileSystem, IModelCatalog, IModelService, - IProjectLocalConfigService, IProviderService, ISessionBtwService, ISessionContext, @@ -186,18 +186,26 @@ import { ISessionExportService, ISessionIndex, ISessionInitService, - ISessionLifecycleService, - ISessionMcpService, + ISessionMcpHandle, ISessionMetadata, ISessionSecondaryModelWarningService, ISessionSkillCatalog, - ISessionWorkspaceCommandService, ISessionWorkspaceContext, ISkillCatalogRuntimeOptions, ISkillDiscovery, ITelemetryService, IWorkspaceAliases, hostRequestHeadersSeed, + IWorkspaceDirs, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, + closeSessionById, + followWorkspaceHandlers, + getLiveSessionById, + handlerForSession, + resumeSessionById, + sessionDirOf, + workspacePersistenceScope, logSeed, MAIN_AGENT_ID, prepareSystemPromptContext, @@ -454,10 +462,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (translated !== undefined) this.receiveEvent(translated); }), // A session closed without going through this client (archive, an - // engine-initiated close) drops its wiring with the scope. - this.app.accessor.get(ISessionLifecycleService).onDidCloseSession((closed) => { - this.unwireSession(closed.sessionId); - }), + // engine-initiated close) drops its wiring with the scope. Close events + // fire per workspace handler, so follow every handler — present and + // future — through the App-scope registry. + followWorkspaceHandlers(this.app.accessor, (service) => + service.onDidCloseSession((closed) => { + this.unwireSession(closed.sessionId); + }), + ), ); } @@ -682,7 +694,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // // The v2 engine splits what v1's SessionStore + in-memory session map did // across the app-scope `ISessionIndex` (persisted read model), - // `ISessionLifecycleService` (live session scopes), and the session-scope + // `IWorkspaceLifecycleService` (live workspace handlers and, under them, the + // live session scopes), and the session-scope // metadata/workspace services. The klient facade covers listing and the // metadata mutations of a LIVE session; everything that needs an explicit // session id, a resume, or a workspace command goes through the @@ -698,8 +711,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // the host supplies one). // ----------------------------------------------------------------------- - private get sessionLifecycle(): ISessionLifecycleService { - return this.engineAccessor.get(ISessionLifecycleService); + private liveSession(sessionId: string): ISessionScopeHandle | undefined { + return getLiveSessionById(this.engineAccessor, sessionId); } /** v1's `requireSession` / store lookup failure shape. */ @@ -711,7 +724,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** The live session handle, or the error v1 raises for a non-active session. */ private requireLiveSession(sessionId: string): ISessionScopeHandle { - const handle = this.sessionLifecycle.get(sessionId); + const handle = this.liveSession(sessionId); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); return handle; } @@ -906,7 +919,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { summaries.push( v2SummaryToSessionSummary(item, { workDir, - sessionDir: bootstrapService.sessionDir(item.workspaceId, item.id), + sessionDir: sessionDirOf( + bootstrapService.homeDir, + workspacePersistenceScope(bootstrapService.scope('sessions'), item.workspaceId), + item.id, + ), }), ); } @@ -915,7 +932,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1 semantics: register the workDir as a workspace and create the session - * (the v2 `sessionLifecycleService.create` does both; the klient facade + * (the handler's `IWorkspaceHandlerService.create` does both; the klient facade * wrapper is bypassed because it takes neither an explicit session id nor * caller metadata). The `model` / `thinking` / `permission` options are the * main-agent configuration v1 applies eagerly at creation: supplying any of @@ -931,7 +948,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); if (input.id !== undefined) { const existing = - this.sessionLifecycle.get(input.id) ?? + this.liveSession(input.id) ?? (await this.engineAccessor.get(ISessionIndex).get(input.id)); if (existing !== undefined) { throw new KimiError( @@ -940,7 +957,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); } } - const handle = await this.sessionLifecycle.create({ + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, @@ -982,21 +1002,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (title.length === 0) { throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); } - if (this.sessionLifecycle.get(input.id) !== undefined) { + if (this.liveSession(input.id) !== undefined) { await this.klient.session(input.id).setTitle(title); return; } - const handle = await this.sessionLifecycle.resume(input.id); + const handle = await resumeSessionById(this.engineAccessor, input.id); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); try { await this.klient.session(input.id).setTitle(title); } finally { - await this.sessionLifecycle.close(input.id); + await closeSessionById(this.engineAccessor, input.id); } } /** - * Through `engineAccessor` (`ISessionLifecycleService.fork`) because the + * Through `engineAccessor` (the handler chain's `IWorkspaceHandlerService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the * engine's fork is unconditional — it never rejects an in-flight source * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no @@ -1011,7 +1031,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { 'forkSession turnIndex truncation is not wired to agent-core-v2 yet.', ); } - const handle = await this.sessionLifecycle.fork({ + const forkHandler = await handlerForSession(this.engineAccessor, input.id); + if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ sourceSessionId: input.id, newSessionId: input.forkId, title: input.title, @@ -1029,7 +1051,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * Materializes the session through `engineAccessor` - * (`ISessionLifecycleService.resume`; the facade only offers `restore`, + * (`resumeSessionById` through the handler chain; the facade only offers `restore`, * which would also clear the archived flag — v1's resume does not). * `includeSubagents` / `replayTurnLimit` shape the returned per-agent * snapshot exactly like v1: subagent states are folded best-effort from @@ -1037,19 +1059,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * most recent N user turns via the shared `limitAgentReplayByTurns`. */ override async resumeSession(input: ResumeSessionInput): Promise { - const handle = await this.sessionLifecycle.resume(input.id); + // v1 re-resolves caller-provided additional dirs on every resume and + // merges them over the workspace-local set; the engine's resume options + // union them into the handler's shared in-memory set while the session + // scope is materialized. Unlike v1, the v2 + // engine has no caller `mcpServers` channel on create/resume (caller + // servers are an ACP-side concern to be designed separately). + const handle = await resumeSessionById(this.engineAccessor, input.id, { + additionalDirs: input.additionalDirs, + }); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); this.wireSession(handle); - // v1 re-resolves caller-provided additional dirs on every resume and - // merges them over the workspace-local set; the engine's own resume only - // restores the workspace-local ones. - if (input.additionalDirs !== undefined && input.additionalDirs.length > 0) { - const workspace = handle.accessor.get(ISessionWorkspaceContext); - const resolved = await this.engineAccessor - .get(IProjectLocalConfigService) - .resolveAdditionalDirs(workspace.workDir, input.additionalDirs); - workspace.setAdditionalDirs([...workspace.additionalDirs, ...resolved]); - } return this.resumedSessionSummary(handle, { includeSubagents: input.includeSubagents, replayTurnLimit: input.replayTurnLimit, @@ -1066,7 +1086,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async reloadSession(input: ReloadSessionRpcInput): Promise { const sessionId = input.sessionId; - const live = this.sessionLifecycle.get(sessionId); + const live = this.liveSession(sessionId); if (live !== undefined) { for (const agent of live.accessor.get(IAgentLifecycleService).list()) { if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { @@ -1084,12 +1104,12 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { await this.klient.global.config.reload(); await this.klient.global.plugins.reload(); if (live !== undefined) { - await this.sessionLifecycle.close(sessionId); + await closeSessionById(this.engineAccessor, sessionId); } // Same print-steer reset as closeSession: v1's reload rebuilds the // Session, and with it the counters. this.printSteerStates.delete(sessionId); - const handle = await this.sessionLifecycle.resume(sessionId); + const handle = await resumeSessionById(this.engineAccessor, sessionId); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); this.wireSession(handle); return this.resumedSessionSummary(handle); @@ -1108,18 +1128,20 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through `engineAccessor` (`ISessionWorkspaceCommandService`, session - * scope) — no klient facade exists for workspace commands. The v2 service - * returns the same `{additionalDirs, projectRoot, configPath, persisted}` - * shape as v1. Gap: with `persist: false` v1 also writes the dir into the - * session metadata so it survives a resume; v2 keeps it in memory only, so - * a non-persisted dir is lost on resume. + * Through the session's handler (`IWorkspaceDirs`, workspace scope) — the + * workspace-level add-dir surface: `persist: true` (default) appends to the + * project-local `.kimi-code/local.toml`, `persist: false` joins the + * handler's shared in-memory set. The set is shared by every session of + * the workspace (a v1 `persist: false` dir was session-scoped and written + * into session metadata to survive a resume; the v2 handler keeps it for + * every session of the workspace until the process exits). Returns the + * same `{additionalDirs, projectRoot, configPath, persisted}` shape as v1. */ override async addAdditionalDir(input: AddAdditionalDirInput): Promise { const handle = this.requireLiveSession(input.id); return handle.accessor - .get(ISessionWorkspaceCommandService) - .addAdditionalDir({ path: input.path, persist: input.persist }); + .get(IWorkspaceDirs) + .addDir({ path: input.path, persist: input.persist }); } /** @@ -1931,9 +1953,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // MCP: the user-global surface is rebuilt over the SDK-side store port in // `src/v2/global-mcp.ts` plus the v2 engine's own OAuth service and // connection manager (agent-core-v2 has no app-scope MCP config service — - // it only reads `mcp.json` — and binds its OAuth orchestrator inside the - // session scope); the session-level reads go through the session scope's - // `ISessionMcpService` (no klient facade exists for either group). + // it only reads `mcp.json`); the session-level reads go through the + // session scope's seeded `ISessionMcpHandle` (no klient facade exists for + // either group). // ----------------------------------------------------------------------- /** v1's per-core `globalMcpOAuth`, built over the app-scope document store. */ @@ -2057,21 +2079,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through the session scope (`ISessionMcpService.connectionManager()`). - * Both engines settle the initial connect before create/resume returns, so - * the entry list is final here; the v2 `McpServerEntry` is field-identical - * with v1's `McpServerInfo` (the cast bridges the two packages' type - * declarations). + * Through the session scope (the seeded `ISessionMcpHandle.connectionManager` + * — the workspace handler's one shared manager). Both engines settle the + * initial connect before create/resume returns, so the entry list is final + * here; the v2 `McpServerEntry` is field-identical with v1's + * `McpServerInfo` (the cast bridges the two packages' type declarations). */ override async listMcpServers(input: SessionIdRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - return mcp.connectionManager().list() as readonly McpServerInfo[]; + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + return mcp.connectionManager.list() as readonly McpServerInfo[]; } override async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - await mcp.connectionManager().waitForInitialLoad(); - return { durationMs: mcp.connectionManager().initialLoadDurationMs() }; + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + await mcp.connectionManager.waitForInitialLoad(); + return { durationMs: mcp.connectionManager.initialLoadDurationMs() }; } /** @@ -2080,8 +2102,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * re-registration rides on the status listeners in both engines. */ override async reconnectMcpServer(input: ReconnectMcpServerRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - await mcp.connectionManager().reconnect(input.name); + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + await mcp.connectionManager.reconnect(input.name); } } diff --git a/packages/node-sdk/src/v2/global-mcp.ts b/packages/node-sdk/src/v2/global-mcp.ts index 8ed70c939..905776ca5 100644 --- a/packages/node-sdk/src/v2/global-mcp.ts +++ b/packages/node-sdk/src/v2/global-mcp.ts @@ -27,7 +27,7 @@ import { type McpRemoteServerConfig, type McpServerConfig, } from '@moonshot-ai/agent-core'; -import type { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import type { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { atomicWrite } from '@moonshot-ai/agent-core-v2/_base/utils/fs'; import type { McpTestResult } from '#/types'; diff --git a/packages/node-sdk/test/session-context.test.ts b/packages/node-sdk/test/session-context.test.ts index 503c416de..36f9564d0 100644 --- a/packages/node-sdk/test/session-context.test.ts +++ b/packages/node-sdk/test/session-context.test.ts @@ -48,12 +48,12 @@ describe('Session context', () => { it('clears context without replacing the session', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-context-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-context-work-'); - const additionalDir = await makeTempDir(tempDirs, 'kimi-sdk-context-additional-'); + await writeTestConfig(homeDir, 200_000); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); try { const session = await harness.createSession({ id: 'ses_context_clear', workDir }); - await session.addAdditionalDir(additionalDir, { persist: false }); + await session.importContext('Earlier context to clear.', "file 'notes.md'"); await expect(session.getContext()).resolves.toMatchObject({ history: [{ role: 'user' }], }); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index b391344b0..2cc2499a7 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -17,8 +17,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ISessionApprovalService, - ISessionLifecycleService, ISessionQuestionService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { @@ -169,9 +169,9 @@ function scrubHomePrefixes(value: unknown, home: HomePair): unknown { */ const KNOWN_DIFFS = { // v2's flag registry is per-domain and already carries flags v1 does not - // have (minidb backend, subagent); v1-only flags would be - // the symmetric case. Parity is enforced on the intersection of ids until - // the registries are unified. + // have (minidb backend, subagent); v1-only flags would be the symmetric + // case. Parity is enforced on the intersection of ids until the registries + // are unified. getExperimentalFeatures: ( features: readonly { id: string }[], other: readonly { id: string }[], @@ -3710,6 +3710,33 @@ describe('v1↔v2 global MCP parity', () => { }, 20_000); }); +type McpServerList = Awaited>; + +/** + * List MCP servers on both engines once the initial connect has settled. + * v1 connects in the background after create resolves while v2 awaits the + * initial connect inside create, so an immediate list can catch either side + * still pending under load; poll until no server reports a transient status. + */ +async function listMcpServersWhenSettled( + pair: SessionParityPair, + input: { readonly sessionId: string }, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const lists = await Promise.all([ + pair.v1.listMcpServers(input), + pair.v2.listMcpServers(input), + ] as const); + const settled = lists.every((servers) => + servers.every((server) => server.status !== 'pending'), + ); + if (settled || Date.now() >= deadline) return lists; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + describe('v1↔v2 session MCP parity', () => { /** working (connects, 3 tools) + broken (fails fast) + off (disabled). */ const SESSION_MCP_FIXTURE = { @@ -3735,10 +3762,7 @@ describe('v1↔v2 session MCP parity', () => { try { await createOnBoth(pair, { id: 'session_parity_mcp_list' }); const input = { sessionId: 'session_parity_mcp_list' } as const; - const [v1Servers, v2Servers] = await Promise.all([ - pair.v1.listMcpServers(input), - pair.v2.listMcpServers(input), - ]); + const [v1Servers, v2Servers] = await listMcpServersWhenSettled(pair, input); expect(normalize(v2Servers, 'name')).toEqual(normalize(v1Servers, 'name')); const byName = new Map(v1Servers.map((server) => [server.name, server])); expect(byName.get('working')).toMatchObject({ @@ -4051,7 +4075,7 @@ describe('v1↔v2 event & interaction parity', () => { try { await createOnBoth(pair, { id: 'session_parity_events_approval' }); const sessionId = 'session_parity_events_approval'; - const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); expect(v2Session).toBeDefined(); const v2Approvals = v2Session!.accessor.get(ISessionApprovalService); const requestInput = { @@ -4130,7 +4154,7 @@ describe('v1↔v2 event & interaction parity', () => { try { await createOnBoth(pair, { id: 'session_parity_events_question' }); const sessionId = 'session_parity_events_question'; - const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); expect(v2Session).toBeDefined(); const v2Questions = v2Session!.accessor.get(ISessionQuestionService); const requestInput = { diff --git a/packages/protocol/src/__tests__/rest-fs.test.ts b/packages/protocol/src/__tests__/rest-fs.test.ts index cf9a5d376..786869143 100644 --- a/packages/protocol/src/__tests__/rest-fs.test.ts +++ b/packages/protocol/src/__tests__/rest-fs.test.ts @@ -242,8 +242,8 @@ describe('fsSearchRequestSchema (W11.1)', () => { }); }); - it('rejects empty query', () => { - expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false); + it('accepts an empty query (workspace-root listing)', () => { + expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(true); }); it('caps limit at 200', () => { diff --git a/packages/protocol/src/rest/fs.ts b/packages/protocol/src/rest/fs.ts index 157ee0915..2eef1f051 100644 --- a/packages/protocol/src/rest/fs.ts +++ b/packages/protocol/src/rest/fs.ts @@ -27,6 +27,8 @@ * Body: FsSearchRequest { query, limit?, include_globs?, exclude_globs?, * follow_gitignore? } * Response data: FsSearchResponse { items, truncated } + * An empty query lists the workspace root's top-level entries (dirs + * first) instead of fuzzy-matching. * Errors: 40401, 41303, 41304 * * POST /v1/sessions/{sid}/fs:grep @@ -221,7 +223,10 @@ export const fsStatManyResponseSchema = z.object({ export type FsStatManyResponse = z.infer; export const fsSearchRequestSchema = z.object({ - query: z.string().min(1), + // An empty query is allowed: the server then lists the workspace root's + // top-level entries (dirs first) instead of fuzzy-matching — the starting + // set for @-mention style file pickers. + query: z.string(), limit: z.number().int().min(1).max(200).default(50), include_globs: z.array(z.string()).optional(), exclude_globs: z.array(z.string()).optional(), From bb2919eb818a6cb51c71cbabf1bac9020131bce7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 11:35:39 +0800 Subject: [PATCH 6/6] fix(tui): reduce frequent full-screen redraws (#2442) --- .changeset/tui-drop-forced-full-redraws.md | 5 + .../tui/components/editor/custom-editor.ts | 33 ----- apps/kimi-code/src/tui/kimi-tui.ts | 35 ++--- .../components/editor/custom-editor.test.ts | 126 +----------------- 4 files changed, 18 insertions(+), 181 deletions(-) create mode 100644 .changeset/tui-drop-forced-full-redraws.md diff --git a/.changeset/tui-drop-forced-full-redraws.md b/.changeset/tui-drop-forced-full-redraws.md new file mode 100644 index 000000000..90403031f --- /dev/null +++ b/.changeset/tui-drop-forced-full-redraws.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reduce frequent full-screen redraws in the TUI. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index b1677e87b..51958dbe9 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -16,7 +16,6 @@ import { import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; import { printableChar } from '#/tui/utils/printable-key'; -import { isInsideTmux } from '#/tui/utils/terminal-notification'; import { extractAtPrefix } from './file-mention-provider'; import { WrappingSelectList } from './wrapping-select-list'; @@ -163,7 +162,6 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; private argumentHints: ReadonlyMap = new Map(); - private autocompleteWasShowing = false; setArgumentHints(hints: ReadonlyMap): void { this.argumentHints = hints; @@ -258,38 +256,7 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } - // Force a full re-render when the autocomplete dropdown closes, so the editor - // snaps back to the bottom instead of sitting where the taller dropdown left it. - // Only worthwhile when the session content already overflows one screen; below - // that a full clear + home would pull the editor to the top and leave a blank - // tail. Always skipped inside tmux, whose own reflow handles the shrink. - private requestFullRenderOnAutocompleteClose(): void { - if (isInsideTmux()) return; - const { columns, rows } = this.tui.terminal; - // Redraw when content fills or overflows the viewport. An exact fill (== - // rows) is safe to clear (no blank tail) and still needs the redraw: the - // differential renderer keeps the old viewport offset after a shrink. - if (this.tui.render(columns).length < rows) return; - this.tui.requestRender(true); - } - - // Detect an autocomplete open→close edge from a render frame and force a full - // re-render. Running from render() (not handleInput) also catches asynchronous - // closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels - // the menu once the provider re-query resolves. The render request is deferred - // to a microtask so the overflow probe inside the helper does not re-enter - // render() synchronously. - private trackAutocompleteCloseForFullRender(): void { - const showing = this.isShowingAutocomplete(); - const closed = this.autocompleteWasShowing && !showing; - this.autocompleteWasShowing = showing; - if (closed) { - queueMicrotask(() => this.requestFullRenderOnAutocompleteClose()); - } - } - override render(width: number): string[] { - this.trackAutocompleteCloseForFullRender(); const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c9f1d7d..59807b835 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2052,11 +2052,10 @@ export class KimiTUI { this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); - // Session resets (/new, /clear, session switch) want a pristine screen. - // Force a destructive full render: the renderer's collapse repaint - // intentionally preserves scrollback, which would leave the previous - // session's text above the welcome banner. - this.state.ui.requestRender(true); + // No forced full render on session reset: let the differential renderer + // converge on its own (a mass change above the viewport still makes the + // engine repaint everything, but nothing is forced destructively here). + this.state.ui.requestRender(); } private isTurnBoundaryComponent(child: Component): boolean { @@ -2550,12 +2549,10 @@ export class KimiTUI { if (!isExpandable(child)) continue; child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } - // Expanding/collapsing shifts content above the viewport; the clamped - // differential render would paint a second copy below the stale one in - // scrollback. This is a deliberate user action (like /clear), so do a - // destructive full render: scrollback holds exactly one copy and the - // expanded output can be read by scrolling up. - this.state.ui.requestRender(true); + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); } toggleTodoPanelExpansion(): void { @@ -2793,18 +2790,10 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - // Measure overflow against the restored tree (editor mounted), not the tall - // panel just removed — otherwise a short session with a tall panel looks like - // it overflows and we take a full clear/home that yanks the editor to the top. - // Treat an exact one-screen fill as overflowing too: a full redraw is safe - // there (no blank tail) and clears a stale viewport offset after a shrink. - const { columns, rows } = this.state.terminal; - const overflowsViewport = this.state.ui.render(columns).length >= rows; - // Force a full re-render after replacing a tall panel with the shorter editor: - // differential rendering leaves the editor shifted up when the bottom-anchored - // region shrinks in place. Skip under tmux (its own reflow handles the shrink) - // and when content fits on one screen (a full clear would pull the editor up). - this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); + // Differential render only: closing a tall panel leaves the editor a few + // rows above the bottom (blank tail) until the next append, but avoids a + // destructive full redraw on every dialog close. + this.state.ui.requestRender(); } restoreInputText(text: string): void { diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index cf7184b3b..f66c92e7d 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -4,7 +4,7 @@ import type { AutocompleteSuggestions, TUI, } from '@moonshot-ai/pi-tui'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -788,127 +788,3 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); - -describe('CustomEditor full re-render on autocomplete close', () => { - function makeEditorWithRenderSpy(contentLines: number): { - editor: CustomEditor; - requestRender: ReturnType; - } { - const requestRender = vi.fn(); - const tui = { - requestRender, - terminal: { rows: 40, cols: 120 }, - render: vi.fn(() => Array.from({ length: contentLines }, () => '')), - } as unknown as TUI; - return { editor: new CustomEditor(tui), requestRender }; - } - - // Drive one render frame so the render-edge detector observes the menu state. - function renderFrame(editor: CustomEditor): void { - editor.render(120); - } - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); // record wasShowing = true - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); // close edge -> schedule helper - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); - - it('keeps differential rendering when the content fits on one screen', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(10); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).not.toHaveBeenCalledWith(true); - }); - - it('forces a full re-render when the content exactly fills one screen', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(40); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); - - it('does not force a full re-render inside tmux', async () => { - vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0'); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).not.toHaveBeenCalledWith(true); - }); - - it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - const provider: AutocompleteProvider = { - getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => { - const text = (lines[cursorLine] ?? '').slice(0, cursorCol); - if (!text.startsWith('/')) return { items: [], prefix: text }; - return { items: [{ value: 'help', label: 'help' }], prefix: '/' }; - }), - applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), - }; - editor.setAutocompleteProvider(provider); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); // record wasShowing = true - - editor.handleInput(''); // Backspace deletes the '/' - await flushAutocomplete(); - await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); // close edge -> schedule helper - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); -});