mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
667898b9df
commit
ec9dae72ab
15 changed files with 180 additions and 37 deletions
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: [] }],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
18
packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts
Normal file
18
packages/agent-core-v2/src/app/skillCatalog/cliSkillDirs.ts
Normal 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');
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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); }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue