From ec9dae72abbbd522722cfb0f4191d84602008643 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 9 Jul 2026 11:40:43 +0800 Subject: [PATCH] fix: make release-e2e scenarios pass under agent-core-v2 Three independent fixes for release-e2e failures that only appeared with the experimental v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG): - agent-core-v2: register the KIMI_MODEL env overlay statically so it takes effect even when ModelService is not instantiated (the DI layer does not auto-instantiate Eager services). Fixes wire-llm-request-trace. - cli: omit the leading system.version meta line in stream-json prompt mode so the role sequence stays clean. Fixes stream-json-cron. - agent-core-v2: honor --skills-dir via a new explicit skill source seeded from the host. Fixes interactive-skills-dir. Cherry-picked from 2a7232737 (v2-migration), excluding the node-sdk V2Host change (not applicable on this branch). --- apps/kimi-code/src/cli/run-prompt.ts | 23 ++----- .../kimi-code/src/cli/v2/create-v2-harness.ts | 11 +++- apps/kimi-code/test/cli/run-prompt.test.ts | 9 ++- .../src/app/bootstrap/bootstrap.ts | 4 ++ .../app/config/configOverlayContributions.ts | 34 ++++++++++ .../src/app/config/configService.ts | 7 ++ .../agent-core-v2/src/app/model/envOverlay.ts | 6 ++ .../src/app/model/modelService.ts | 10 +-- .../src/app/skillCatalog/cliSkillDirs.ts | 18 +++++ packages/agent-core-v2/src/index.ts | 3 + .../src/session/cron/tools/cron-delete.ts | 2 +- .../explicitSkillSource.ts | 65 +++++++++++++++++++ .../skillCatalogService.ts | 12 ++-- .../agent-core-v2/test/cron/tools.test.ts | 2 +- .../agent-core-v2/test/model/model.test.ts | 11 ++++ 15 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 packages/agent-core-v2/src/app/config/configOverlayContributions.ts create mode 100644 packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts create mode 100644 packages/agent-core-v2/src/session/sessionSkillCatalog/explicitSkillSource.ts diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 7f7b5942f..6bc7ba757 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -110,7 +110,7 @@ export async function runPrompt( // can identify which build produced the stream, in both `text` and `stream-json` // formats. The default v1 path keeps its established output unchanged. if (isKimiV2Enabled()) { - writeExperimentalVersion(version, outputFormat, stdout, stderr); + writeExperimentalVersion(version, outputFormat, stderr); } const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -673,27 +673,16 @@ interface PromptJsonResumeMetaMessage { content: string; } -interface PromptJsonVersionMetaMessage { - role: 'meta'; - type: 'system.version'; - version: string; -} - function writeExperimentalVersion( version: string, outputFormat: PromptOutputFormat, - stdout: PromptOutput, stderr: PromptOutput, ): void { - if (outputFormat === 'stream-json') { - const message: PromptJsonVersionMetaMessage = { - role: 'meta', - type: 'system.version', - version, - }; - stdout.write(`${JSON.stringify(message)}\n`); - return; - } + // Stream-json output is consumed by structured pipelines whose role + // sequence is asserted downstream (e.g. release-e2e stream-json-cron); + // emitting a leading version meta line breaks that contract. Keep the + // experimental version banner on stderr for text mode only. + if (outputFormat === 'stream-json') return; stderr.write(`kimi version ${version}\n`); } diff --git a/apps/kimi-code/src/cli/v2/create-v2-harness.ts b/apps/kimi-code/src/cli/v2/create-v2-harness.ts index 9c511431e..c0ce03f78 100644 --- a/apps/kimi-code/src/cli/v2/create-v2-harness.ts +++ b/apps/kimi-code/src/cli/v2/create-v2-harness.ts @@ -10,6 +10,7 @@ import { IAgentPermissionModeService, IAgentProfileService, + ICliSkillDirs, IConfigService, ISessionIndex, ISessionLifecycleService, @@ -18,6 +19,8 @@ import { logSeed, resolveLoggingConfig, type Scope, + type ScopeSeed, + type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; import { KimiAuthFacade, @@ -50,7 +53,13 @@ export async function createV2Harness(options: KimiHarnessOptions): Promise, { dirs: options.skillDirs ?? [] }], + ]; + const { app: core } = bootstrap({ homeDir, configPath }, [ + ...logSeed(logging), + ...cliSkillDirsSeed, + ]).app; const auth = new KimiAuthFacade({ homeDir, configPath, diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index ba4d634ba..a2b04e742 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1076,7 +1076,7 @@ describe('runPrompt', () => { expect(stdout.text()).toBe('• hello world\n\n'); }); - it('emits the version first in stream-json mode when the experimental flag is enabled', async () => { + it('does not emit the version meta in stream-json mode even when the experimental flag is enabled', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); const stdout = writer(); const stderr = writer(); @@ -1088,11 +1088,10 @@ describe('runPrompt', () => { expect(mocks.createV2Harness).toHaveBeenCalled(); expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); - const lines = stdout.text().split('\n'); - expect(lines[0]).toBe( - '{"role":"meta","type":"system.version","version":"1.2.3-test"}', - ); expect(stderr.text()).toBe(''); + // The version banner is intentionally omitted in stream-json mode so the + // role sequence stays clean for structured consumers. + expect(stdout.text()).not.toContain('system.version'); }); it('does not emit the version when the experimental flag is disabled', async () => { diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 9fb487d69..c118f5c01 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -26,6 +26,7 @@ import { import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { ICliSkillDirs } from '#/app/skillCatalog/cliSkillDirs'; export interface IBootstrapOptions { readonly homeDir: string; @@ -164,6 +165,9 @@ function skillSeed(): ScopeSeed { ISkillDiscovery as ServiceIdentifier, new SyncDescriptor(FileSkillDiscovery, [], true), ], + // Default empty so `ExplicitSkillSource` resolves cleanly when the host + // does not inject any `--skills-dir`. Hosts override via `extraSeeds`. + [ICliSkillDirs as ServiceIdentifier, { dirs: [] }], ]; } diff --git a/packages/agent-core-v2/src/app/config/configOverlayContributions.ts b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts new file mode 100644 index 000000000..0ebc9a460 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts @@ -0,0 +1,34 @@ +/** + * `config` domain (L2) — module-level config-overlay contribution collector. + * + * Mirrors `configSectionContributions.ts` but for `ConfigEffectiveOverlay`s. + * An owner domain calls `registerConfigOverlay(...)` at the top level of the + * module that defines the overlay; `ConfigRegistry` drains the collected + * overlays when it is constructed. Pure data — no DI, no container — so + * `config` never imports any owner domain, and an overlay becomes active as + * soon as its owning module is imported, regardless of whether the consuming + * Service is instantiated. + * + * This decouples overlay registration from Service lifetime: an overlay must + * not depend on an `Eager` Service being constructed, since the DI layer does + * not auto-instantiate `Eager` services (see `ModelService` / + * `kimiModelEnvOverlay`). + */ + +import type { ConfigEffectiveOverlay } from './config'; + +const _overlays: ConfigEffectiveOverlay[] = []; + +/** Record a config-overlay contribution for `ConfigRegistry` to drain. */ +export function registerConfigOverlay(overlay: ConfigEffectiveOverlay): void { + _overlays.push(overlay); +} + +export function getConfigOverlayContributions(): readonly ConfigEffectiveOverlay[] { + return _overlays; +} + +/** Test isolation — mirrors `_clearConfigSectionContributionsForTests`. */ +export function _clearConfigOverlayContributionsForTests(): void { + _overlays.length = 0; +} diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 7de8028a2..0add03be1 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -49,6 +49,7 @@ import { } from './config'; import { deepEqual, deepMerge, describeUnknownError, isPlainObject } from './configPure'; import { getConfigSectionContributions } from './configSectionContributions'; +import { getConfigOverlayContributions } from './configOverlayContributions'; import { applySectionToToml, camelToSnake, @@ -150,6 +151,12 @@ export class ConfigRegistry implements IConfigRegistry { for (const c of getConfigSectionContributions()) { this.registerSection(c.domain, c.schema, c.options); } + // Drain module-level overlay contributions (see + // `configOverlayContributions.ts`) for the same reason: an overlay must + // take effect even if its owning Service is never instantiated. + for (const overlay of getConfigOverlayContributions()) { + this.registerEffectiveOverlay(overlay); + } } registerSection( diff --git a/packages/agent-core-v2/src/app/model/envOverlay.ts b/packages/agent-core-v2/src/app/model/envOverlay.ts index 51f64773b..749044c14 100644 --- a/packages/agent-core-v2/src/app/model/envOverlay.ts +++ b/packages/agent-core-v2/src/app/model/envOverlay.ts @@ -14,6 +14,7 @@ import { parseBooleanEnv } from '#/_base/utils/env'; import type { ConfigEffectiveOverlay } from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; import { ErrorCodes, KimiError } from '#/errors'; import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider'; @@ -219,3 +220,8 @@ function collectModelOverrides(input: { } return Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined; } + +// Self-register at module load so the overlay takes effect even when +// `ModelService` is never instantiated (the DI layer does not auto-instantiate +// `Eager` services). Drained by `ConfigRegistry` on construction. +registerConfigOverlay(kimiModelEnvOverlay); diff --git a/packages/agent-core-v2/src/app/model/modelService.ts b/packages/agent-core-v2/src/app/model/modelService.ts index 36f5b477d..2f3170913 100644 --- a/packages/agent-core-v2/src/app/model/modelService.ts +++ b/packages/agent-core-v2/src/app/model/modelService.ts @@ -12,9 +12,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; -import { IConfigRegistry, IConfigService } from '#/app/config/config'; - -import { kimiModelEnvOverlay } from './envOverlay'; +import { IConfigService } from '#/app/config/config'; import { type ModelAlias, type ModelsChangedEvent, @@ -28,12 +26,8 @@ export class ModelService extends Disposable implements IModelService { private readonly _onDidChangeModels = this._register(new Emitter()); readonly onDidChangeModels: Event = this._onDidChangeModels.event; - constructor( - @IConfigRegistry registry: IConfigRegistry, - @IConfigService private readonly config: IConfigService, - ) { + constructor(@IConfigService private readonly config: IConfigService) { super(); - registry.registerEffectiveOverlay(kimiModelEnvOverlay); this._register( config.onDidChangeConfiguration((e) => { if (e.domain === MODELS_SECTION) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts b/packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts new file mode 100644 index 000000000..3ce5e8e9b --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts @@ -0,0 +1,18 @@ +/** + * `skillCatalog` domain (L3) — CLI-injected skill directory carrier. + * + * Holds the `--skills-dir` values passed by the host (CLI / SDK), seeded into + * the App scope at bootstrap so the Session-scope `ExplicitSkillSource` can + * resolve them relative to each session's `workDir`. Defaults to empty when + * the host does not inject any. App-scoped token, pure data. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ICliSkillDirs { + readonly _serviceBrand: undefined; + readonly dirs: readonly string[]; +} + +export const ICliSkillDirs: ServiceIdentifier = + createDecorator('cliSkillDirs'); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 18bd3c12f..f009d0714 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -89,6 +89,7 @@ export * from '#/app/protocol/errors'; export * from '#/app/protocol/protocol'; export * from '#/app/protocol/protocolAdapterRegistry'; import '#/app/model/configSection'; +import '#/app/model/envOverlay'; export * from '#/app/model/completionBudget'; export * from '#/app/model/model'; export type { @@ -140,10 +141,12 @@ export * from '#/app/skillCatalog/skillRoots'; export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; +export * from '#/app/skillCatalog/cliSkillDirs'; export * from '#/session/sessionSkillCatalog/skillCatalog'; export * from '#/session/sessionSkillCatalog/skillCatalogService'; export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; export * from '#/session/sessionSkillCatalog/pluginSkillSource'; +export * from '#/session/sessionSkillCatalog/explicitSkillSource'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; import '#/app/flag/flag'; diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts index d97571cea..923e69976 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts @@ -83,7 +83,7 @@ export class CronDeleteTool implements BuiltinTool { isError: true, output: `Invalid cron job id ${JSON.stringify( args.id, - )} — must be a ULID or 8 lowercase hex characters.`, + )} — must be 8 lowercase hex characters or a ULID.`, }; } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitSkillSource.ts new file mode 100644 index 000000000..cb3898df5 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitSkillSource.ts @@ -0,0 +1,65 @@ +/** + * `sessionSkillCatalog` domain (L3) — CLI explicit-directory `ISkillSource`. + * + * Discovers skills from the host-injected `--skills-dir` values + * (`ICliSkillDirs`) through `ISkillDiscovery`, contributing them at priority + * 30 (above plugin, matching v1 where explicit dirs win every name collision). + * Bound at Session scope so relative dirs resolve against each session's + * `workDir`. When the host injects no dirs, contributes nothing. + */ + +import { homedir } from 'node:os'; + +import path from 'pathe'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ICliSkillDirs } from '#/app/skillCatalog/cliSkillDirs'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; +import type { SkillRoot } from '#/app/skillCatalog/types'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +export interface IExplicitSkillSource extends ISkillSource { + readonly _serviceBrand: undefined; +} + +export const IExplicitSkillSource: ServiceIdentifier = + createDecorator('explicitSkillSource'); + +export class ExplicitSkillSource implements IExplicitSkillSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'cli'; + readonly priority = 30; + + constructor( + @ISkillDiscovery private readonly discovery: ISkillDiscovery, + @ICliSkillDirs private readonly cliSkillDirs: ICliSkillDirs, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + ) {} + + async load(): Promise { + if (this.cliSkillDirs.dirs.length === 0) return { skills: [] }; + const roots: SkillRoot[] = this.cliSkillDirs.dirs.map((dir) => ({ + path: this.resolveDir(dir), + source: 'user', + })); + return this.discovery.discover(roots); + } + + private resolveDir(dir: string): string { + const expanded = + dir === '~' || dir.startsWith('~/') ? path.join(homedir(), dir.slice(1)) : dir; + return path.isAbsolute(expanded) ? expanded : path.join(this.workspace.workDir, expanded); + } +} + +registerScopedService( + LifecycleScope.Session, + IExplicitSkillSource, + ExplicitSkillSource, + InstantiationType.Delayed, + 'sessionSkillCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 09c570a0f..0521f7957 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -1,10 +1,10 @@ /** * `sessionSkillCatalog` domain (L3) — `ISessionSkillCatalog` sink implementation. * - * Dumb ordered-merge table: pulls the four eager `ISkillSource`s (builtin / - * user / workspace / plugin) and folds their contributions into an in-memory + * Dumb ordered-merge table: pulls the eager `ISkillSource`s (builtin / user / + * workspace / plugin / cli) and folds their contributions into an in-memory * catalog by priority, so higher-priority sources win name collisions. `ready` - * resolves once all four have completed their first `load()`+merge; a source's + * resolves once all have completed their first `load()`+merge; a source's * `onDidChange` (e.g. plugin reload) re-pulls just that source and re-merges, * firing `onDidChange`. `set`/`remove` (`ISkillCatalogSink`) let ad-hoc sources * push contributions. Bound at Session scope; the same instance is the @@ -24,6 +24,7 @@ import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import { IPluginSkillSource } from './pluginSkillSource'; import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog'; import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource'; +import { IExplicitSkillSource } from './explicitSkillSource'; export class SessionSkillCatalogService extends Disposable @@ -46,9 +47,12 @@ export class SessionSkillCatalogService @IUserFileSkillSource user: IUserFileSkillSource, @IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IExplicitSkillSource explicit: IExplicitSkillSource, ) { super(); - this.sources = [builtin, user, workspace, plugin].toSorted((a, b) => a.priority - b.priority); + this.sources = [builtin, user, workspace, plugin, explicit].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); })); } diff --git a/packages/agent-core-v2/test/cron/tools.test.ts b/packages/agent-core-v2/test/cron/tools.test.ts index e5e9f3665..cd38cc19b 100644 --- a/packages/agent-core-v2/test/cron/tools.test.ts +++ b/packages/agent-core-v2/test/cron/tools.test.ts @@ -467,7 +467,7 @@ describe('CronDeleteTool', () => { const output = assertError(await runTool(tool, { id })); - expect(output).toContain('must be a ULID or 8 lowercase hex characters'); + expect(output).toContain('must be 8 lowercase hex characters or a ULID'); expect(harness.store.list()).toHaveLength(1); expect(harness.deleted).toEqual([]); }, diff --git a/packages/agent-core-v2/test/model/model.test.ts b/packages/agent-core-v2/test/model/model.test.ts index c61f5f500..12aea586f 100644 --- a/packages/agent-core-v2/test/model/model.test.ts +++ b/packages/agent-core-v2/test/model/model.test.ts @@ -342,4 +342,15 @@ describe('kimiModelEnvOverlay', () => { ).toBe('user'); expect(kimiModelEnvOverlay.strip?.('modelOverrides', { temperature: 0.3 }, {})).toBeUndefined(); }); + + it('self-registers into ConfigRegistry without ModelService instantiation', () => { + // envOverlay.ts calls registerConfigOverlay(kimiModelEnvOverlay) at module + // load, so a freshly constructed ConfigRegistry drains it even though no + // Service (notably ModelService) has been instantiated. This guards the + // release-e2e wire-llm-request-trace scenario, where KIMI_MODEL_NAME must + // synthesize the env model (and its thinking capability) even when nothing + // resolves IModelService. + const freshRegistry = new ConfigRegistry(); + expect(freshRegistry.listEffectiveOverlays()).toContain(kimiModelEnvOverlay); + }); });