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).
This commit is contained in:
7Sageer 2026-07-09 11:40:43 +08:00
parent 667898b9df
commit ec9dae72ab
15 changed files with 180 additions and 37 deletions

View file

@ -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`);
}

View file

@ -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<Prom
const homeDir = resolveKimiHome(options.homeDir);
const configPath = resolveConfigPath({ homeDir, configPath: options.configPath });
const logging = resolveLoggingConfig({ homeDir, env: process.env });
const { app: core } = bootstrap({ homeDir, configPath }, logSeed(logging));
const cliSkillDirsSeed: ScopeSeed = [
[ICliSkillDirs as ServiceIdentifier<unknown>, { dirs: options.skillDirs ?? [] }],
];
const { app: core } = bootstrap({ homeDir, configPath }, [
...logSeed(logging),
...cliSkillDirsSeed,
]).app;
const auth = new KimiAuthFacade({
homeDir,
configPath,

View file

@ -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 () => {

View file

@ -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<unknown>,
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<unknown>, { dirs: [] }],
];
}

View file

@ -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;
}

View file

@ -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<T>(

View file

@ -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);

View file

@ -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<ModelsChangedEvent>());
readonly onDidChangeModels: Event<ModelsChangedEvent> = 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) {

View file

@ -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<ICliSkillDirs> =
createDecorator<ICliSkillDirs>('cliSkillDirs');

View file

@ -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';

View file

@ -83,7 +83,7 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
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.`,
};
}

View file

@ -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<IExplicitSkillSource> =
createDecorator<IExplicitSkillSource>('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<SkillContribution> {
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',
);

View file

@ -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); }));
}

View file

@ -467,7 +467,7 @@ describe('CronDeleteTool', () => {
const output = assertError(await runTool<CronDeleteInput>(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([]);
},

View file

@ -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);
});
});