mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-04 14:02:22 +00:00
Merge branch 'main' into auto-title
Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
commit
64099bd801
50 changed files with 766 additions and 413 deletions
|
|
@ -67,4 +67,4 @@ Invariants that hold across every stage. Each is expanded in the stage file note
|
|||
9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md)
|
||||
10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md)
|
||||
11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md)
|
||||
12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md)
|
||||
12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md)
|
||||
|
|
|
|||
|
|
@ -42,9 +42,16 @@ If it fails any rule, it is not Config:
|
|||
|
||||
**`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by
|
||||
all domains — the env bag, resolved paths, and host facts (`platform`, `arch`,
|
||||
`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific
|
||||
upper domain (no `cron`, no `flags`, no feature-specific fields): that couples
|
||||
the foundational layer to an upstream one.
|
||||
`cwd`, `osHomeDir`, `isCI`, …) — plus the host's process-level invocation
|
||||
arguments in `args` (explicit `agentFiles` / `skillDirs`, `requestHeaders`,
|
||||
prompt identity). `args` mirrors VS Code's `NativeParsedArgs` on the
|
||||
environment service: the host states them once via `BootstrapInput.args` at
|
||||
the composition root, and downstream services read them from
|
||||
`IBootstrapService.args` instead of through per-domain runtime-options
|
||||
services (do not add new `IXxxRuntimeOptions` services or seed functions for
|
||||
host parameters). What must **never** land on `IBootstrapService` is state
|
||||
tied to a specific upper domain (no `cron`, no `flags`, no feature-specific
|
||||
fields): that couples the foundational layer to an upstream one.
|
||||
|
||||
Any value that belongs to a specific domain — including env-only operational
|
||||
toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature
|
||||
|
|
@ -264,7 +271,7 @@ The authoritative, always-current list of registered sections — rendered in th
|
|||
- `config` never imports the domains that consume it — keep section schemas in the owning domain.
|
||||
- Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code.
|
||||
- Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays.
|
||||
- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`.
|
||||
- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`.
|
||||
- Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections.
|
||||
- `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`.
|
||||
- Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file.
|
||||
|
|
|
|||
|
|
@ -37,13 +37,11 @@ import {
|
|||
ITelemetryService,
|
||||
PRINT_MAX_TURNS_DEFAULT,
|
||||
PRINT_WAIT_CEILING_S_DEFAULT,
|
||||
agentCatalogRuntimeOptionsSeed,
|
||||
applyPrintModeConfigDefaults,
|
||||
bootstrap,
|
||||
createCloudAppender,
|
||||
ensureMainAgent,
|
||||
resumeSessionById,
|
||||
hostRequestHeadersSeed,
|
||||
logSeed,
|
||||
parseAgentFileText,
|
||||
resolveAgentPath,
|
||||
|
|
@ -51,7 +49,6 @@ import {
|
|||
resolveKimiHome,
|
||||
resolveLoggingConfig,
|
||||
resolvePrintBackgroundMode,
|
||||
skillCatalogRuntimeOptionsSeed,
|
||||
type DomainEvent,
|
||||
type IAgentScopeHandle,
|
||||
type ISessionScopeHandle,
|
||||
|
|
@ -130,18 +127,24 @@ export async function runV2Print(
|
|||
const identity = createKimiCodeHostIdentity(version);
|
||||
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
|
||||
|
||||
const { app } = bootstrap({ homeDir, clientIdentity: identity }, [
|
||||
...logSeed(logging),
|
||||
...hostRequestHeadersSeed(hostHeaders),
|
||||
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
|
||||
// user / project discovery for this process.
|
||||
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
|
||||
// `--agent-file`: explicit agent definition files, registered with the
|
||||
// highest-precedence source for this process. Passed through unresolved —
|
||||
// the engine expands `~` and resolves relative paths against the session
|
||||
// workDir (mirroring `--skills-dir`).
|
||||
...agentCatalogRuntimeOptionsSeed(opts.agentFiles),
|
||||
]);
|
||||
const { app } = bootstrap(
|
||||
{
|
||||
homeDir,
|
||||
clientIdentity: identity,
|
||||
args: {
|
||||
requestHeaders: hostHeaders,
|
||||
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
|
||||
// user / project discovery for this process.
|
||||
skillDirs: opts.skillsDirs,
|
||||
// `--agent-file`: explicit agent definition files, registered with the
|
||||
// highest-precedence source for this process. Passed through unresolved —
|
||||
// the engine expands `~` and resolves relative paths against the session
|
||||
// workDir (mirroring `--skills-dir`).
|
||||
agentFiles: opts.agentFiles,
|
||||
},
|
||||
},
|
||||
[...logSeed(logging)],
|
||||
);
|
||||
const auth = app.accessor.get(IOAuthToolkit);
|
||||
|
||||
const configService = app.accessor.get(IConfigService);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
import {
|
||||
removeProviderFromConfig,
|
||||
type CreateSessionOptions,
|
||||
type KimiConfig,
|
||||
type KimiHarness,
|
||||
type OAuthRef,
|
||||
type Session,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { createKimiCodeUserAgent } from '#/cli/version';
|
||||
|
||||
|
|
@ -7,6 +14,7 @@ import type { SkillListSession } from '../commands';
|
|||
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
|
||||
import {
|
||||
refreshAllProviderModels,
|
||||
type RefreshProviderHost,
|
||||
type RefreshProviderScope,
|
||||
type RefreshResult,
|
||||
} from '../utils/refresh-providers';
|
||||
|
|
@ -172,23 +180,68 @@ export class AuthFlowController {
|
|||
}
|
||||
|
||||
private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> {
|
||||
const { host } = this;
|
||||
const result = await refreshAllProviderModels(
|
||||
{
|
||||
getConfig: () => host.harness.getConfig({ reload: true }),
|
||||
removeProvider: (id) => host.harness.removeProvider(id),
|
||||
setConfig: (patch) => host.harness.setConfig(patch),
|
||||
resolveOAuthToken: async (providerName, oauthRef) => {
|
||||
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
|
||||
return tokenProvider.getAccessToken();
|
||||
},
|
||||
userAgent: createKimiCodeUserAgent(),
|
||||
},
|
||||
{ scope },
|
||||
);
|
||||
const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope });
|
||||
if (result.changed.length > 0) {
|
||||
await this.refreshAvailableModels();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the refresh orchestrator's persistence host. When the harness can
|
||||
* persist several config sections as ONE atomic write (the v2 engine's
|
||||
* `replaceSections`), the orchestrator's two-phase contract (removeProvider
|
||||
* then setConfig) is absorbed the same way the v2 engine's own refresh path
|
||||
* does it: the removal is staged in memory only, and the following
|
||||
* setConfig persists the complete records in a single write — so a process
|
||||
* exit mid-refresh can never leave config.toml in a "provider removed, not
|
||||
* yet restored" state. The v1 harness keeps the legacy host (two
|
||||
* whole-document writes, each atomic on its own).
|
||||
*/
|
||||
private buildRefreshHost(): RefreshProviderHost {
|
||||
const { host } = this;
|
||||
const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise<string> => {
|
||||
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
|
||||
return tokenProvider.getAccessToken();
|
||||
};
|
||||
const userAgent = createKimiCodeUserAgent();
|
||||
if (!host.harness.supportsAtomicSectionReplace()) {
|
||||
return {
|
||||
getConfig: () => host.harness.getConfig({ reload: true }),
|
||||
removeProvider: (id) => host.harness.removeProvider(id),
|
||||
setConfig: (patch) => host.harness.setConfig(patch),
|
||||
resolveOAuthToken,
|
||||
userAgent,
|
||||
};
|
||||
}
|
||||
let staged: KimiConfig | undefined;
|
||||
const requireStaged = (): KimiConfig => {
|
||||
if (staged === undefined) {
|
||||
throw new Error('refresh host: getConfig must be called before writes');
|
||||
}
|
||||
return staged;
|
||||
};
|
||||
return {
|
||||
getConfig: async () => {
|
||||
staged = await host.harness.getConfig({ reload: true });
|
||||
return staged;
|
||||
},
|
||||
removeProvider: (id) => {
|
||||
staged = removeProviderFromConfig(requireStaged(), id);
|
||||
return Promise.resolve(staged);
|
||||
},
|
||||
setConfig: async (patch) => {
|
||||
// The orchestrator always passes complete records (built from a full
|
||||
// clone), so the Partial-shaped patch is a full KimiConfig overlay.
|
||||
staged = { ...requireStaged(), ...patch } as KimiConfig;
|
||||
// Object.entries keeps keys whose value is `undefined`, so a cleared
|
||||
// section (e.g. a dangling defaultModel) is expressed as a removal in
|
||||
// the atomic write; sections absent from the patch stay untouched.
|
||||
await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch)));
|
||||
return staged;
|
||||
},
|
||||
resolveOAuthToken,
|
||||
userAgent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ export class KimiTUI {
|
|||
private uninstallRainbowDance: () => void;
|
||||
private signalCleanupHandlers: Array<() => void> = [];
|
||||
private isShuttingDown = false;
|
||||
private backgroundRefreshPromise: Promise<void> | undefined;
|
||||
private readonly migrationPlan: MigrationPlan | null;
|
||||
private readonly migrateOnly: boolean;
|
||||
private readonly engineV2: boolean;
|
||||
|
|
@ -748,7 +749,7 @@ export class KimiTUI {
|
|||
private async init(): Promise<boolean> {
|
||||
setExperimentalFeatures(await this.harness.getExperimentalFeatures());
|
||||
await this.authFlow.refreshAvailableModels();
|
||||
void this.refreshProviderModelsInBackground();
|
||||
this.backgroundRefreshPromise = this.refreshProviderModelsInBackground();
|
||||
|
||||
const { startup } = this.options;
|
||||
const { workDir } = this.state.appState;
|
||||
|
|
@ -852,6 +853,16 @@ export class KimiTUI {
|
|||
this.isShuttingDown = true;
|
||||
this.unregisterSignalHandlers();
|
||||
this.aborted = true;
|
||||
// Give the startup provider-model refresh a brief chance to finish before
|
||||
// the harness closes (and the process exits): its config writes are each
|
||||
// atomic, so draining can only ever leave a complete file behind. Bounded
|
||||
// so a slow network never delays the exit.
|
||||
if (this.backgroundRefreshPromise !== undefined) {
|
||||
await Promise.race([
|
||||
this.backgroundRefreshPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
}
|
||||
this.streamingUI.discardPending();
|
||||
// Stop background polling, streaming intervals, and per-component timers
|
||||
// before tearing the UI down, so they can't keep firing requestRender after
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { join } from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
IAgentCatalogRuntimeOptions,
|
||||
IAgentGoalService,
|
||||
IAgentLifecycleService,
|
||||
IAgentPermissionModeService,
|
||||
|
|
@ -22,10 +21,9 @@ import {
|
|||
ISessionIndex,
|
||||
ISessionLifecycleService,
|
||||
IWorkspaceLifecycleService,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
ITelemetryService,
|
||||
type BootstrapInput,
|
||||
type DomainEvent,
|
||||
type ScopeSeed,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import { runV2Print } from '../../src/cli/v2/run-v2-print';
|
||||
|
|
@ -295,7 +293,7 @@ describe('runV2Print', () => {
|
|||
expect(app.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => {
|
||||
it('passes explicit skill dirs from --skillsDir into bootstrap args', async () => {
|
||||
const stdout = writer();
|
||||
const stderr = writer();
|
||||
const { app, agent } = makeFakeHarness();
|
||||
|
|
@ -308,12 +306,11 @@ describe('runV2Print', () => {
|
|||
stderr,
|
||||
});
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions);
|
||||
expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] });
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.skillDirs).toEqual(['/skills']);
|
||||
});
|
||||
|
||||
it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => {
|
||||
it('leaves the skill dirs arg unset when --skillsDir is empty', async () => {
|
||||
const stdout = writer();
|
||||
const stderr = writer();
|
||||
const { app, agent } = makeFakeHarness();
|
||||
|
|
@ -323,8 +320,8 @@ describe('runV2Print', () => {
|
|||
|
||||
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false);
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.skillDirs ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => {
|
||||
|
|
@ -341,9 +338,8 @@ describe('runV2Print', () => {
|
|||
{ stdout, stderr },
|
||||
);
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
|
||||
expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] });
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.agentFiles).toEqual(['/agents/reviewer.md']);
|
||||
|
||||
const lifecycle = handlerServices.get(ISessionLifecycleService) as {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -376,9 +372,8 @@ describe('runV2Print', () => {
|
|||
stderr,
|
||||
});
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
|
||||
expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] });
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.agentFiles).toEqual([agentFile]);
|
||||
|
||||
const lifecycle = handlerServices.get(ISessionLifecycleService) as {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -430,7 +425,7 @@ describe('runV2Print', () => {
|
|||
expect(profile.bind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the agent runtime options unseeded when --agentFile is empty', async () => {
|
||||
it('leaves the agent files arg unset when --agentFile is empty', async () => {
|
||||
const stdout = writer();
|
||||
const stderr = writer();
|
||||
const { app, agent } = makeFakeHarness();
|
||||
|
|
@ -440,8 +435,8 @@ describe('runV2Print', () => {
|
|||
|
||||
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
expect(seeds.some(([id]) => id === IAgentCatalogRuntimeOptions)).toBe(false);
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.agentFiles ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes --agent-file paths through unresolved so the engine can expand ~', async () => {
|
||||
|
|
@ -458,9 +453,8 @@ describe('runV2Print', () => {
|
|||
{ stdout, stderr },
|
||||
);
|
||||
|
||||
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
|
||||
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
|
||||
expect(seeded?.[1]).toMatchObject({ explicitFiles: ['~/agents/reviewer.md'] });
|
||||
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
|
||||
expect(input.args?.agentFiles).toEqual(['~/agents/reviewer.md']);
|
||||
});
|
||||
|
||||
it('treats re-selecting the already-bound profile on resume as a no-op', async () => {
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown>
|
|||
track: vi.fn(),
|
||||
setTelemetryContext: vi.fn(),
|
||||
getExperimentalFeatures: vi.fn(async () => []),
|
||||
supportsAtomicSectionReplace: vi.fn(() => false),
|
||||
auth: {
|
||||
status: vi.fn(async () => ({ providers: [] })),
|
||||
login: vi.fn(async () => {}),
|
||||
|
|
@ -1140,6 +1141,121 @@ describe('KimiTUI startup', () => {
|
|||
expect(showStatus).toHaveBeenCalledWith("New Models · +2 models.");
|
||||
});
|
||||
|
||||
it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => {
|
||||
const registryUrl = "https://registry.example.test/v1/models/api.json";
|
||||
const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" };
|
||||
const replaceConfigSections = vi.fn(async (_sections: Record<string, unknown>) => {});
|
||||
const removeProvider = vi.fn(async () => ({}));
|
||||
const setConfig = vi.fn(async () => ({}));
|
||||
const harness = makeHarness(makeSession(), {
|
||||
supportsAtomicSectionReplace: vi.fn(() => true),
|
||||
replaceConfigSections,
|
||||
removeProvider,
|
||||
setConfig,
|
||||
getConfig: vi.fn(async () => ({
|
||||
providers: {
|
||||
a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source },
|
||||
b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source },
|
||||
},
|
||||
models: {
|
||||
"a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
|
||||
"b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
|
||||
},
|
||||
defaultModel: "b/m1",
|
||||
thinking: { enabled: true },
|
||||
})),
|
||||
});
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
id: "a",
|
||||
name: "Provider A",
|
||||
api: "https://a.example.test/v1",
|
||||
type: "openai",
|
||||
models: { m1: { id: "m1" } },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await (driver as any).authFlow.refreshProviderModels();
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 });
|
||||
// The removal was staged in memory: no destructive pre-write, exactly
|
||||
// one atomic section replace carrying the complete records — with the
|
||||
// dangling default model / thinking expressed as cleared sections.
|
||||
expect(removeProvider).not.toHaveBeenCalled();
|
||||
expect(setConfig).not.toHaveBeenCalled();
|
||||
expect(replaceConfigSections).toHaveBeenCalledTimes(1);
|
||||
const sections = replaceConfigSections.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(Object.keys(sections["providers"] as object)).toEqual(["a"]);
|
||||
expect(sections["models"]).not.toHaveProperty("b/m1");
|
||||
expect(sections["defaultModel"]).toBeUndefined();
|
||||
expect(sections["thinking"]).toBeUndefined();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => {
|
||||
const registryUrl = "https://registry.example.test/v1/models/api.json";
|
||||
const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" };
|
||||
const replaceConfigSections = vi.fn(async () => {});
|
||||
const removeProvider = vi.fn(async () => ({}));
|
||||
const setConfig = vi.fn(async (patch: Record<string, unknown>) => patch);
|
||||
const harness = makeHarness(makeSession(), {
|
||||
replaceConfigSections,
|
||||
removeProvider,
|
||||
setConfig,
|
||||
getConfig: vi.fn(async () => ({
|
||||
providers: {
|
||||
a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source },
|
||||
b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source },
|
||||
},
|
||||
models: {
|
||||
"a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
|
||||
"b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
|
||||
},
|
||||
defaultModel: "b/m1",
|
||||
})),
|
||||
});
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
id: "a",
|
||||
name: "Provider A",
|
||||
api: "https://a.example.test/v1",
|
||||
type: "openai",
|
||||
models: { m1: { id: "m1" } },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await (driver as any).authFlow.refreshProviderModels();
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(removeProvider).toHaveBeenCalledWith("b");
|
||||
expect(setConfig).toHaveBeenCalledTimes(1);
|
||||
expect(replaceConfigSections).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts TUI without a session when fresh startup needs OAuth login", async () => {
|
||||
const harness = makeHarness(makeSession(), {
|
||||
createSession: vi.fn(async () => {
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@
|
|||
* Kosong directories that do not exist yet are skipped silently (later
|
||||
* refactor phases add them).
|
||||
*
|
||||
* Intra-package relative imports and `#/`-alias imports are resolved against
|
||||
* `src/`. Sibling packages (`@moonshot-ai/*` other than v1) and third-party
|
||||
* imports are out of scope (except for the kosong purity bans above).
|
||||
* Intra-package relative imports, `#/`-alias imports, and the package's
|
||||
* self-reference (`@moonshot-ai/agent-core-v2/<path>` → `src/<path>`) are
|
||||
* resolved against `src/`. Sibling packages (`@moonshot-ai/*` other than v1)
|
||||
* and third-party imports are out of scope (except for the kosong purity
|
||||
* bans above).
|
||||
*
|
||||
* Run: `node scripts/check-import-boundaries.mjs`. Exits non-zero on violation.
|
||||
*/
|
||||
|
|
@ -42,6 +44,7 @@ export const SRC_ROOT = join(PKG_ROOT, 'src');
|
|||
const TEST_ROOT = join(PKG_ROOT, 'test');
|
||||
|
||||
const V1_PACKAGE = '@moonshot-ai/agent-core';
|
||||
const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/';
|
||||
|
||||
/**
|
||||
* Scope directories introduced by the `src/{scope}/{domain}` layout. A path's
|
||||
|
|
@ -172,6 +175,11 @@ function resolveIntraV2(specifier, fromFile) {
|
|||
if (specifier.startsWith('#/')) {
|
||||
return join(SRC_ROOT, specifier.slice(2));
|
||||
}
|
||||
// The package's legal self-reference: `@moonshot-ai/agent-core-v2/x` maps
|
||||
// to `src/x` via the `./*` export.
|
||||
if (specifier.startsWith(SELF_PACKAGE_PREFIX)) {
|
||||
return join(SRC_ROOT, specifier.slice(SELF_PACKAGE_PREFIX.length));
|
||||
}
|
||||
if (specifier.startsWith('.')) {
|
||||
return resolve(dirname(fromFile), specifier);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryCon
|
|||
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, type LoadedAgentsMd } from './context';
|
||||
import type {
|
||||
ApplyProfileOptions,
|
||||
|
|
@ -220,7 +219,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
|
|||
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
|
||||
@IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
@IHostIdentity private readonly hostIdentity: IHostIdentity,
|
||||
@IPluginService private readonly plugins: IPluginService,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -871,8 +869,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
|
|||
skills,
|
||||
pluginSections,
|
||||
skillActive: this.isToolActiveForProfile(profile, 'Skill'),
|
||||
productName: this.hostIdentity.productName,
|
||||
replyStyleGuide: this.hostIdentity.replyStyleGuide,
|
||||
productName: this.bootstrap.args.displayName,
|
||||
replyStyleGuide: this.bootstrap.args.replyStyleGuide,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
* state after a successful Kimi login), whose bearer token comes from
|
||||
* `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from
|
||||
* the provider's `baseUrl`. The explicit config wins over the managed
|
||||
* derivation. Both use the host's Kimi identity headers (`IHostRequestHeaders`)
|
||||
* as default headers. When neither source is configured it yields `undefined`.
|
||||
* derivation. Both use the host's Kimi identity headers
|
||||
* (`IBootstrapService.args.requestHeaders`) as default headers. When neither
|
||||
* source is configured it yields `undefined`.
|
||||
* Tests and hosts that need a custom backend bind `IWebSearchProviderService`
|
||||
* directly. Bound at App scope.
|
||||
*/
|
||||
|
|
@ -22,8 +23,8 @@ import {
|
|||
|
||||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
|
|||
constructor(
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
) {}
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
|
|||
baseUrl: search.baseUrl,
|
||||
tokenProvider,
|
||||
apiKey: nonEmptyString(search.apiKey),
|
||||
defaultHeaders: { ...this.hostHeaders.headers },
|
||||
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
|
||||
customHeaders: search.customHeaders,
|
||||
});
|
||||
}
|
||||
|
|
@ -80,7 +81,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
|
|||
return new MoonshotWebSearchProvider({
|
||||
baseUrl,
|
||||
tokenProvider,
|
||||
defaultHeaders: { ...this.hostHeaders.headers },
|
||||
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
|
||||
customHeaders: provider.customHeaders,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@
|
|||
*
|
||||
* Defines the `IBootstrapService`, the snapshot of the world the process runs
|
||||
* in, resolved once at startup and frozen for the process: observed host facts
|
||||
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`) and the
|
||||
* app path layout (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is
|
||||
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`), the
|
||||
* app path layout (`homeDir`, `configPath`, …), and the host's process-level
|
||||
* invocation arguments (`args` — mirroring VS Code's `NativeParsedArgs`
|
||||
* carried on the environment service: the host states them once in
|
||||
* `BootstrapInput`; downstream services read them here instead of through
|
||||
* per-domain runtime-options services). `resolveBootstrapOptions` is
|
||||
* the single place that reads `process.env` / `os.homedir()` / invocation
|
||||
* input to resolve the snapshot; everything downstream reads from
|
||||
* `IBootstrapService` instead of touching `process` directly. Bound at App
|
||||
|
|
@ -30,6 +34,56 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe
|
|||
import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
|
||||
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
|
||||
|
||||
/**
|
||||
* Host invocation arguments — process-level overrides the embedding host
|
||||
* states once at startup (mirrors VS Code's `NativeParsedArgs` carried on the
|
||||
* environment service). Resolved from {@link HostArgsInput} and read via
|
||||
* `IBootstrapService.args`.
|
||||
*/
|
||||
export interface HostArgs {
|
||||
/**
|
||||
* Explicit agent definition files for this process (the CLI's
|
||||
* `--agent-file`): loaded as the highest-priority `explicit` agent-profile
|
||||
* source. Undefined means no explicit files.
|
||||
*/
|
||||
readonly agentFiles?: readonly string[];
|
||||
/**
|
||||
* Explicit skill directories for this process (v1's SDK `skillDirs`): when
|
||||
* non-empty, default user / project skill discovery is skipped and these
|
||||
* directories serve as the user skill source.
|
||||
*/
|
||||
readonly skillDirs?: readonly string[];
|
||||
/**
|
||||
* Host identity headers applied to outbound provider requests (User-Agent +
|
||||
* `X-Msh-*`, built by the host through `createKimiDefaultHeaders`).
|
||||
* Materialized to `{}` when the host passes none.
|
||||
*/
|
||||
readonly requestHeaders: Readonly<Record<string, string>>;
|
||||
/** Fills the `${product_name}` slot in the base system-prompt template. */
|
||||
readonly displayName?: string;
|
||||
/** Replaces the `${reply_style_guide}` block in the base system prompt. */
|
||||
readonly replyStyleGuide?: string;
|
||||
}
|
||||
|
||||
/** {@link HostArgs} as accepted from the host: `requestHeaders` may be omitted. */
|
||||
export interface HostArgsInput {
|
||||
readonly agentFiles?: readonly string[];
|
||||
readonly skillDirs?: readonly string[];
|
||||
readonly requestHeaders?: Readonly<Record<string, string>>;
|
||||
readonly displayName?: string;
|
||||
readonly replyStyleGuide?: string;
|
||||
}
|
||||
|
||||
export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs {
|
||||
return {
|
||||
agentFiles: input?.agentFiles,
|
||||
skillDirs: input?.skillDirs,
|
||||
requestHeaders: input?.requestHeaders ?? {},
|
||||
displayName: input?.displayName,
|
||||
replyStyleGuide: input?.replyStyleGuide,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IBootstrapOptions {
|
||||
readonly homeDir: string;
|
||||
readonly configPath: string;
|
||||
|
|
@ -39,6 +93,7 @@ export interface IBootstrapOptions {
|
|||
readonly cwd: string;
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly clientIdentity: KimiHostIdentity;
|
||||
readonly args: HostArgs;
|
||||
}
|
||||
|
||||
export const IBootstrapOptions: ServiceIdentifier<IBootstrapOptions> =
|
||||
|
|
@ -64,6 +119,8 @@ export interface IBootstrapService {
|
|||
readonly homeDir: string;
|
||||
readonly configPath: string;
|
||||
readonly clientIdentity: KimiHostIdentity;
|
||||
/** Host invocation arguments; see {@link HostArgs}. */
|
||||
readonly args: HostArgs;
|
||||
readonly sessionsDir: string;
|
||||
readonly blobsDir: string;
|
||||
readonly storeDir: string;
|
||||
|
|
@ -85,7 +142,11 @@ export interface BootstrapInput {
|
|||
readonly platform?: NodeJS.Platform;
|
||||
readonly arch?: string;
|
||||
readonly cwd?: string;
|
||||
/** Required: every process names its host. There is deliberately no default
|
||||
— a fabricated identity would silently misreport the host upstream. */
|
||||
readonly clientIdentity: KimiHostIdentity;
|
||||
/** Host invocation arguments; see {@link HostArgsInput}. */
|
||||
readonly args?: HostArgsInput;
|
||||
}
|
||||
|
||||
export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions {
|
||||
|
|
@ -102,6 +163,7 @@ export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOption
|
|||
cwd: input.cwd ?? process.cwd(),
|
||||
env,
|
||||
clientIdentity: input.clientIdentity,
|
||||
args: resolveHostArgs(input.args),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
|
|||
import {
|
||||
IBootstrapOptions,
|
||||
IBootstrapService,
|
||||
type HostArgs,
|
||||
type PersistenceScopeName,
|
||||
} from './bootstrap';
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ export class BootstrapService implements IBootstrapService {
|
|||
readonly homeDir: string;
|
||||
readonly configPath: string;
|
||||
readonly clientIdentity: KimiHostIdentity;
|
||||
readonly args: HostArgs;
|
||||
readonly sessionsDir: string;
|
||||
readonly blobsDir: string;
|
||||
readonly storeDir: string;
|
||||
|
|
@ -50,6 +52,7 @@ export class BootstrapService implements IBootstrapService {
|
|||
this.homeDir = options.homeDir;
|
||||
this.configPath = options.configPath;
|
||||
this.clientIdentity = options.clientIdentity;
|
||||
this.args = options.args;
|
||||
this.sessionsDir = join(options.homeDir, 'sessions');
|
||||
this.blobsDir = join(options.homeDir, 'blobs');
|
||||
this.storeDir = join(options.homeDir, 'store');
|
||||
|
|
|
|||
|
|
@ -202,7 +202,16 @@ export interface IConfigService {
|
|||
inspect<T = unknown>(domain: string): ConfigInspectValue<T>;
|
||||
getAll(): ResolvedConfig;
|
||||
set(domain: string, patch: unknown, target?: ConfigTarget): Promise<void>;
|
||||
/**
|
||||
* Replace one domain wholesale; `undefined` (or `null`, the wire encoding
|
||||
* of clear — JSON transports cannot carry `undefined`) removes the domain.
|
||||
*/
|
||||
replace(domain: string, value: unknown, target?: ConfigTarget): Promise<void>;
|
||||
/**
|
||||
* Replace several domains in ONE atomic write: a domain mapped to
|
||||
* `undefined` (or `null`, see {@link replace}) is cleared, domains absent
|
||||
* from `sections` are left untouched.
|
||||
*/
|
||||
replaceSections(
|
||||
sections: Readonly<Record<string, unknown>>,
|
||||
target?: ConfigTarget,
|
||||
|
|
|
|||
|
|
@ -331,17 +331,20 @@ export class ConfigService extends Disposable implements IConfigService {
|
|||
target: ConfigTarget = ConfigTarget.User,
|
||||
): Promise<void> {
|
||||
await this.ready;
|
||||
// `null` is the wire encoding of "clear this domain": JSON transports
|
||||
// (klient memory/ipc, kap-server REST/WS) cannot carry `undefined`.
|
||||
const effectiveValue = value === null ? undefined : value;
|
||||
if (target === ConfigTarget.Memory) {
|
||||
if (value === undefined) {
|
||||
if (effectiveValue === undefined) {
|
||||
delete this.memory[domain];
|
||||
} else {
|
||||
this.memory[domain] = this.registry.validate(domain, value);
|
||||
this.memory[domain] = this.registry.validate(domain, effectiveValue);
|
||||
}
|
||||
this.commit('set', [domain]);
|
||||
return;
|
||||
}
|
||||
await this.enqueueStateTransition(async () => {
|
||||
const stripped = this.stripEnv(domain, value);
|
||||
const stripped = this.stripEnv(domain, effectiveValue);
|
||||
if (stripped === undefined) {
|
||||
delete this.raw[domain];
|
||||
} else {
|
||||
|
|
@ -363,7 +366,7 @@ export class ConfigService extends Disposable implements IConfigService {
|
|||
const staged: ResolvedConfig = { ...this.memory };
|
||||
for (const domain of domains) {
|
||||
const value = sections[domain];
|
||||
if (value === undefined) {
|
||||
if (value === undefined || value === null) {
|
||||
delete staged[domain];
|
||||
} else {
|
||||
staged[domain] = this.registry.validate(domain, value);
|
||||
|
|
@ -376,7 +379,9 @@ export class ConfigService extends Disposable implements IConfigService {
|
|||
await this.enqueueStateTransition(async () => {
|
||||
const staged: ResolvedConfig = { ...this.raw };
|
||||
for (const domain of domains) {
|
||||
const stripped = this.stripEnv(domain, sections[domain]);
|
||||
// Same `null`-means-clear encoding as `replace` (see above).
|
||||
const value = sections[domain] === null ? undefined : sections[domain];
|
||||
const stripped = this.stripEnv(domain, value);
|
||||
if (stripped === undefined) {
|
||||
delete staged[domain];
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
/**
|
||||
* `hostIdentity` domain — runtime identity of the embedding host.
|
||||
*
|
||||
* Holds process-level overrides the host product (CLI, desktop, …) injects at
|
||||
* the composition root: `displayName` fills the `${product_name}` slot in the
|
||||
* base system-prompt template, `replyStyleGuide` replaces the
|
||||
* `${reply_style_guide}` block (the CLI default describes Markdown rendering
|
||||
* in a terminal). Composition roots set them through {@link hostIdentitySeed};
|
||||
* the registered default carries no overrides, so the template renders its CLI
|
||||
* defaults. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope';
|
||||
|
||||
export interface PromptIdentityOverrides {
|
||||
readonly displayName?: string;
|
||||
readonly replyStyleGuide?: string;
|
||||
}
|
||||
|
||||
export interface IHostIdentity {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly productName?: string;
|
||||
readonly replyStyleGuide?: string;
|
||||
}
|
||||
|
||||
export const IHostIdentity: ServiceIdentifier<IHostIdentity> =
|
||||
createDecorator<IHostIdentity>('hostIdentity');
|
||||
|
||||
export class HostIdentity implements IHostIdentity {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
readonly productName?: string,
|
||||
readonly replyStyleGuide?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export function hostIdentitySeed(overrides: PromptIdentityOverrides | undefined): ScopeSeed {
|
||||
if (overrides === undefined) return [];
|
||||
if (overrides.displayName === undefined && overrides.replyStyleGuide === undefined) return [];
|
||||
return [
|
||||
[
|
||||
IHostIdentity as ServiceIdentifier<unknown>,
|
||||
new HostIdentity(overrides.displayName, overrides.replyStyleGuide),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IHostIdentity,
|
||||
HostIdentity,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'hostIdentity',
|
||||
);
|
||||
|
|
@ -46,10 +46,10 @@ import {
|
|||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
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 { type ModelRecord } from '#/kosong/model/model';
|
||||
import {
|
||||
IProviderService,
|
||||
|
|
@ -90,7 +90,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
@IConfigService private readonly config: IConfigService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IEventService private readonly events: IEventService,
|
||||
@IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
) {}
|
||||
|
||||
refreshProviderModels(
|
||||
|
|
@ -181,7 +181,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
removeProvider: (providerId) => this.shapeWithoutProvider(providerId),
|
||||
setConfig: (patch) => this.applyRefreshPatch(patch, exclusion),
|
||||
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
|
||||
userAgent: this.hostRequestHeaders.headers['User-Agent'],
|
||||
userAgent: this.bootstrap.args.requestHeaders['User-Agent'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* `kosongConfig` domain — `IHostRequestHeaders` implementation.
|
||||
*
|
||||
* Bridges kosong's host-headers port to the host invocation args: the headers
|
||||
* are the ones the host stated in `BootstrapInput.args.requestHeaders`
|
||||
* (usually built through `createKimiDefaultHeaders`), exposed through
|
||||
* `IBootstrapService.args`. kosong's model catalog only sees the port. Bound
|
||||
* at App scope.
|
||||
*/
|
||||
|
||||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
|
||||
export class HostRequestHeadersAdapter implements IHostRequestHeaders {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
|
||||
constructor(@IBootstrapService bootstrap: IBootstrapService) {
|
||||
this.headers = bootstrap.args.requestHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IHostRequestHeaders,
|
||||
HostRequestHeadersAdapter,
|
||||
ScopeActivation.OnDemand,
|
||||
'kosongConfig',
|
||||
);
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* `skillCatalog` domain — runtime options for skill discovery.
|
||||
*
|
||||
* Holds process-level runtime overrides that affect how skill roots are
|
||||
* resolved. `explicitDirs` mirrors v1's SDK `skillDirs`: when present, default
|
||||
* user / project discovery is skipped and the explicit directories are used as
|
||||
* the user source. Bound at App scope.
|
||||
*
|
||||
* Composition roots set it through {@link skillCatalogRuntimeOptionsSeed}
|
||||
* — the registered default carries no explicit dirs.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
ScopeActivation,
|
||||
registerScopedService,
|
||||
type ScopeSeed,
|
||||
} from '#/_base/di/scope';
|
||||
|
||||
export interface ISkillCatalogRuntimeOptions {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly explicitDirs?: readonly string[];
|
||||
}
|
||||
|
||||
export const ISkillCatalogRuntimeOptions: ServiceIdentifier<ISkillCatalogRuntimeOptions> =
|
||||
createDecorator<ISkillCatalogRuntimeOptions>('skillCatalogRuntimeOptions');
|
||||
|
||||
export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(readonly explicitDirs?: readonly string[]) {}
|
||||
}
|
||||
|
||||
export function skillCatalogRuntimeOptionsSeed(
|
||||
explicitDirs: readonly string[] | undefined,
|
||||
): ScopeSeed {
|
||||
if (explicitDirs === undefined || explicitDirs.length === 0) return [];
|
||||
return [
|
||||
[
|
||||
ISkillCatalogRuntimeOptions as ServiceIdentifier<unknown>,
|
||||
new SkillCatalogRuntimeOptions(explicitDirs),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
SkillCatalogRuntimeOptions,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'skillCatalog',
|
||||
);
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
|
||||
type MergeAllAvailableSkillsConfig,
|
||||
} from './configSection';
|
||||
import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions';
|
||||
import { ISkillDiscovery } from './skillDiscovery';
|
||||
import { userRoots } from './skillRoots';
|
||||
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource';
|
||||
|
|
@ -41,7 +40,6 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
|
|||
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
|
||||
) {
|
||||
super();
|
||||
this._register(
|
||||
|
|
@ -52,7 +50,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
|
|||
}
|
||||
|
||||
async load(): Promise<SkillContribution> {
|
||||
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
|
||||
if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) {
|
||||
return { skills: [] };
|
||||
}
|
||||
await this.config.ready;
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
* successful Kimi login), routing fetches through the Moonshot fetch service
|
||||
* (`${provider.baseUrl}/fetch`); and (3) the built-in `LocalFetchURLProvider`,
|
||||
* so `FetchURL` keeps working without any configuration. The first two use the
|
||||
* host's Kimi identity headers (`IHostRequestHeaders`) and fall back to the
|
||||
* local fetcher on failure. Reads config and the managed provider lazily on
|
||||
* each `getUrlFetcher()` call so it tracks edits and login state. Bound at
|
||||
* App scope.
|
||||
* host's Kimi identity headers (`IBootstrapService.args.requestHeaders`) and
|
||||
* fall back to the local fetcher on failure. Reads config and the managed
|
||||
* provider lazily on each `getUrlFetcher()` call so it tracks edits and login
|
||||
* state. Bound at App scope.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -23,8 +23,8 @@ import {
|
|||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ export class WebFetchService implements IWebFetchService {
|
|||
constructor(
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
) {
|
||||
this.localFetcher = new LocalFetchURLProvider();
|
||||
|
|
@ -63,7 +63,7 @@ export class WebFetchService implements IWebFetchService {
|
|||
baseUrl: fetchConfig.baseUrl,
|
||||
tokenProvider,
|
||||
apiKey: nonEmptyString(fetchConfig.apiKey),
|
||||
defaultHeaders: { ...this.hostHeaders.headers },
|
||||
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
|
||||
customHeaders: fetchConfig.customHeaders,
|
||||
localFallback: this.localFetcher,
|
||||
});
|
||||
|
|
@ -85,7 +85,7 @@ export class WebFetchService implements IWebFetchService {
|
|||
return new MoonshotFetchURLProvider({
|
||||
baseUrl,
|
||||
tokenProvider,
|
||||
defaultHeaders: { ...this.hostHeaders.headers },
|
||||
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
|
||||
customHeaders: provider.customHeaders,
|
||||
localFallback: this.localFetcher,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ export * from '#/app/kosongConfig/kosongConfig';
|
|||
export * from '#/app/kosongConfig/kosongConfigService';
|
||||
export * from '#/kosong/model/modelOAuth';
|
||||
export * from '#/app/kosongConfig/oauthTokenAdapter';
|
||||
export * from '#/app/kosongConfig/hostRequestHeadersAdapter';
|
||||
export * from '#/app/kosongConfig/discovery';
|
||||
export * from '#/app/kosongConfig/discoveryService';
|
||||
export * from '#/app/kosongConfig/errors';
|
||||
|
|
@ -169,10 +170,8 @@ export {
|
|||
export * from '#/workspace/workspaceAgentProfileLoader/configSection';
|
||||
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';
|
||||
export * from '#/app/plugin/manifest';
|
||||
|
|
@ -193,7 +192,6 @@ export * from '#/agent/skill/skill';
|
|||
export * from '#/agent/skill/skillService';
|
||||
export * from '#/app/skillCatalog/types';
|
||||
export * from '#/app/skillCatalog/configSection';
|
||||
export * from '#/app/skillCatalog/skillCatalogRuntimeOptions';
|
||||
export * from '#/app/skillCatalog/parser';
|
||||
export * from '#/app/skillCatalog/registry';
|
||||
export * from '#/app/skillCatalog/errors';
|
||||
|
|
|
|||
|
|
@ -1,38 +1,21 @@
|
|||
/**
|
||||
* `kosong/model` domain — host-provided default headers for outbound
|
||||
* provider requests.
|
||||
* `kosong/model` domain (L2) — host-provided default headers for outbound
|
||||
* provider requests (port contract).
|
||||
*
|
||||
* The host (CLI / server) builds the full Kimi identity headers (`User-Agent`
|
||||
* + `X-Msh-*`) and seeds them here. Defaults to empty so non-host contexts
|
||||
* (tests, embedders) send no extra headers.
|
||||
* Mirrors v1's `kimiRequestHeaders`: the host (CLI / server) states its Kimi
|
||||
* identity headers (`User-Agent` + `X-Msh-*`) in
|
||||
* `BootstrapInput.args.requestHeaders`; the app-side adapter
|
||||
* (`app/kosongConfig/hostRequestHeadersAdapter`) bridges
|
||||
* `IBootstrapService.args` to this port so kosong stays a pure abstraction
|
||||
* layer. `ModelCatalog` merges them per vendor — the full set for vendors
|
||||
* whose definition declares `hostHeaders: 'full'`, only the `User-Agent` for
|
||||
* everyone else (so device identity never leaks to third-party endpoints).
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
ScopeActivation,
|
||||
registerScopedService,
|
||||
type ScopeSeed,
|
||||
} from '#/_base/di/scope';
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IHostRequestHeaders {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export const IHostRequestHeaders = createDecorator<IHostRequestHeaders>('hostRequestHeaders');
|
||||
|
||||
export class HostRequestHeaders implements IHostRequestHeaders {
|
||||
constructor(readonly headers: Readonly<Record<string, string>> = {}) {}
|
||||
}
|
||||
|
||||
export function hostRequestHeadersSeed(headers: Readonly<Record<string, string>>): ScopeSeed {
|
||||
return [[IHostRequestHeaders as ServiceIdentifier<unknown>, new HostRequestHeaders(headers)]];
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IHostRequestHeaders,
|
||||
HostRequestHeaders,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'model',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
/**
|
||||
* `workspaceAgentProfileLoader` domain — 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-
|
||||
* priority `explicit` source. Composition roots set it through
|
||||
* {@link agentCatalogRuntimeOptionsSeed}; the registered default carries no
|
||||
* explicit files. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
ScopeActivation,
|
||||
registerScopedService,
|
||||
type ScopeSeed,
|
||||
} from '#/_base/di/scope';
|
||||
|
||||
export interface IAgentCatalogRuntimeOptions {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly explicitFiles?: readonly string[];
|
||||
}
|
||||
|
||||
export const IAgentCatalogRuntimeOptions: ServiceIdentifier<IAgentCatalogRuntimeOptions> =
|
||||
createDecorator<IAgentCatalogRuntimeOptions>('agentCatalogRuntimeOptions');
|
||||
|
||||
export class AgentCatalogRuntimeOptions implements IAgentCatalogRuntimeOptions {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(readonly explicitFiles?: readonly string[]) {}
|
||||
}
|
||||
|
||||
export function agentCatalogRuntimeOptionsSeed(
|
||||
explicitFiles: readonly string[] | undefined,
|
||||
): ScopeSeed {
|
||||
if (explicitFiles === undefined || explicitFiles.length === 0) return [];
|
||||
return [
|
||||
[
|
||||
IAgentCatalogRuntimeOptions as ServiceIdentifier<unknown>,
|
||||
new AgentCatalogRuntimeOptions(explicitFiles),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IAgentCatalogRuntimeOptions,
|
||||
AgentCatalogRuntimeOptions,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'workspaceAgentProfileLoader',
|
||||
);
|
||||
|
|
@ -10,7 +10,6 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
|
|||
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';
|
||||
|
|
@ -37,7 +36,6 @@ export class ExplicitAgentProfileLoaderService
|
|||
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,
|
||||
|
|
@ -54,7 +52,7 @@ export class ExplicitAgentProfileLoaderService
|
|||
}
|
||||
|
||||
protected async load(): Promise<AgentProfileContribution> {
|
||||
const files = this.runtimeOptions.explicitFiles ?? [];
|
||||
const files = this.bootstrap.args.agentFiles ?? [];
|
||||
const profiles: AgentProfile[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = resolveAgentPath(file, this.workspace.cwd, this.bootstrap.osHomeDir);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
/**
|
||||
* `workspaceSkillCatalog` domain — 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 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.
|
||||
* Mirrors v1 SDK `skillDirs`: when the host invocation args provide
|
||||
* `skillDirs`, this source contributes those directories as the user source,
|
||||
* resolving relative 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';
|
||||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
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,
|
||||
|
|
@ -36,13 +36,12 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource {
|
|||
|
||||
constructor(
|
||||
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
|
||||
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
|
||||
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
) {}
|
||||
|
||||
async load(): Promise<SkillContribution> {
|
||||
const explicitDirs = this.runtimeOptions.explicitDirs ?? [];
|
||||
const explicitDirs = this.bootstrap.args.skillDirs ?? [];
|
||||
if (explicitDirs.length === 0) {
|
||||
return { skills: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
|
|||
import { TimeoutTimer } from '#/_base/utils/timer';
|
||||
import { subtreeWatchFilter } from '#/_base/utils/paths';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
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 {
|
||||
|
|
@ -59,7 +59,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
|
|||
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
|
||||
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IHostFsWatchService private readonly fsWatch: IHostFsWatchService,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -73,7 +73,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
|
|||
|
||||
async load(): Promise<SkillContribution> {
|
||||
await this.watchReady;
|
||||
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
|
||||
if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) {
|
||||
return { skills: [] };
|
||||
}
|
||||
await this.config.ready;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import { IAgentStateService } from '#/agent/state/agentState';
|
|||
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 { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
|
|
@ -213,7 +212,6 @@ function buildHost(key: string): {
|
|||
host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub());
|
||||
host.stub(IHostEnvironment, stubUnused());
|
||||
host.stub(IHostFileSystem, stubUnused());
|
||||
host.stub(IHostIdentity, stubUnused());
|
||||
host.stub(IBootstrapService, stubUnused());
|
||||
host.stub(ISessionContext, createSessionContextStub());
|
||||
host.stub(ISessionWorkspaceContext, stubUnused());
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { IConfigService } from '#/app/config/config';
|
|||
import { ConfigRegistry } from '#/app/config/configService';
|
||||
import { type DomainEvent, IEventService } from '#/app/event/event';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import { MODELS_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider';
|
||||
|
|
@ -835,10 +835,12 @@ describe('WebSearchProviderService', () => {
|
|||
resolveTokenProvider:
|
||||
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
|
||||
});
|
||||
reg.definePartialInstance(IHostRequestHeaders, {
|
||||
headers: {
|
||||
'User-Agent': 'kimi-code-cli/test',
|
||||
'X-Msh-Device-Id': 'device-test',
|
||||
reg.definePartialInstance(IBootstrapService, {
|
||||
args: {
|
||||
requestHeaders: {
|
||||
'User-Agent': 'kimi-code-cli/test',
|
||||
'X-Msh-Device-Id': 'device-test',
|
||||
},
|
||||
},
|
||||
});
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
import type { ServiceRegistration } from '#/_base/di/test';
|
||||
import {
|
||||
IBootstrapService,
|
||||
resolveHostArgs,
|
||||
type HostArgsInput,
|
||||
type PersistenceScopeName,
|
||||
} from '#/app/bootstrap/bootstrap';
|
||||
|
||||
|
|
@ -18,7 +20,11 @@ export const stubClientIdentity = {
|
|||
platform: 'test_platform',
|
||||
} as const;
|
||||
|
||||
export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService {
|
||||
export function stubBootstrap(
|
||||
homeDir = '/tmp/kimi-home',
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
args: HostArgsInput = {},
|
||||
): IBootstrapService {
|
||||
const scopes: Record<PersistenceScopeName, string> = {
|
||||
config: '',
|
||||
sessions: 'sessions',
|
||||
|
|
@ -39,6 +45,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv
|
|||
configPath: `${homeDir}/config.toml`,
|
||||
configKey: 'config.toml',
|
||||
clientIdentity: stubClientIdentity,
|
||||
args: resolveHostArgs(args),
|
||||
sessionsDir: `${homeDir}/sessions`,
|
||||
blobsDir: `${homeDir}/blobs`,
|
||||
storeDir: `${homeDir}/store`,
|
||||
|
|
|
|||
|
|
@ -1960,6 +1960,31 @@ describe('ConfigService replaceSections', () => {
|
|||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('treats null as clear — the wire encoding JSON transports use for undefined', async () => {
|
||||
const { config, disposables, store } = await createSectionsConfig();
|
||||
const setSpy = vi.spyOn(store, 'set');
|
||||
|
||||
await config.replaceSections({
|
||||
[DEFAULT_MODEL_SECTION]: null,
|
||||
[PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } },
|
||||
});
|
||||
|
||||
expect(setSpy).toHaveBeenCalledTimes(1);
|
||||
expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined();
|
||||
expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined();
|
||||
expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
|
||||
acme: { type: 'openai', apiKey: 'sk-acme-2' },
|
||||
});
|
||||
|
||||
// `replace(domain, null)` clears too, so JSON transports behave
|
||||
// identically to in-process `replace(domain, undefined)` callers.
|
||||
await config.replace(DEFAULT_MODEL_SECTION, 'acme/m1');
|
||||
await config.replace(DEFAULT_MODEL_SECTION, null);
|
||||
expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined();
|
||||
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('fires change events only after all domains have taken effect', async () => {
|
||||
const { config, disposables } = await createSectionsConfig();
|
||||
const domains: string[] = [];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ 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 { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { ConfigRegistry } from '#/app/config/configService';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
|
|
@ -37,7 +38,6 @@ 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,
|
||||
|
|
@ -52,6 +52,7 @@ import '#/kosong/provider/providers/kimi/kimi.contrib';
|
|||
import '#/kosong/provider/providers/standard.contrib';
|
||||
|
||||
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs';
|
||||
import { stubBootstrap } from '../bootstrap/stubs';
|
||||
|
||||
function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } {
|
||||
const published: Array<{ type: string; payload: unknown }> = [];
|
||||
|
|
@ -100,7 +101,10 @@ async function createHost(
|
|||
[IOAuthService, oauth],
|
||||
[IEventService, events],
|
||||
[ILogService, stubLogService()],
|
||||
[IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })],
|
||||
[
|
||||
IBootstrapService,
|
||||
stubBootstrap('/tmp/kimi-home', {}, { requestHeaders: { 'User-Agent': 'kimi-test/1.0' } }),
|
||||
],
|
||||
]);
|
||||
const providers = host.app.accessor.get(IProviderService);
|
||||
const models = host.app.accessor.get(IModelService);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import { DisposableStore } from '#/_base/di/lifecycle';
|
|||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
|
||||
import { LocalFetchURLProvider } from '#/app/web/providers/local-fetch-url';
|
||||
import { MoonshotFetchURLProvider } from '#/app/web/providers/moonshot-fetch-url';
|
||||
|
|
@ -47,10 +47,12 @@ describe('WebFetchService', () => {
|
|||
resolveTokenProvider:
|
||||
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
|
||||
});
|
||||
reg.definePartialInstance(IHostRequestHeaders, {
|
||||
headers: {
|
||||
'User-Agent': 'kimi-code-cli/test',
|
||||
'X-Msh-Device-Id': 'device-test',
|
||||
reg.definePartialInstance(IBootstrapService, {
|
||||
args: {
|
||||
requestHeaders: {
|
||||
'User-Agent': 'kimi-code-cli/test',
|
||||
'X-Msh-Device-Id': 'device-test',
|
||||
},
|
||||
},
|
||||
});
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import {
|
|||
} from '#/kosong/model/catalog';
|
||||
import { ModelCatalog } from '#/kosong/model/catalogService';
|
||||
import '#/kosong/model/errors';
|
||||
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/model/model';
|
||||
import '#/kosong/model/modelService';
|
||||
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
|
||||
|
|
@ -75,7 +75,7 @@ function createHost(
|
|||
const host = createScopedTestHost([
|
||||
[IConfigService, config],
|
||||
[IModelOAuthTokens, oauthTokens],
|
||||
[IHostRequestHeaders, new HostRequestHeaders(HOST_HEADERS)],
|
||||
[IHostRequestHeaders, { headers: HOST_HEADERS }],
|
||||
]);
|
||||
// Kosong's registries are pure in-memory stores now (persistence lives in
|
||||
// the app/kosongConfig bridge): seed them from the fixture sections.
|
||||
|
|
@ -807,7 +807,7 @@ describe('ModelCatalog ping', () => {
|
|||
models,
|
||||
stubModelOAuthTokens(),
|
||||
registry,
|
||||
new HostRequestHeaders({}),
|
||||
{ headers: {} },
|
||||
);
|
||||
const result = await catalog.ping('k1');
|
||||
expect(result).toMatchObject({ ok: true, text: 'pong', finishReason: 'completed' });
|
||||
|
|
|
|||
|
|
@ -136,4 +136,13 @@ describe('check-import-boundaries', () => {
|
|||
);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves the package self-reference as an intra-v2 import', () => {
|
||||
const violations = checkSource(
|
||||
`import { Foo } from '@moonshot-ai/agent-core-v2/kosong/provider/provider';`,
|
||||
atKosong('protocol', 'protocol.ts'),
|
||||
);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.message).toMatch(/kosong layer violation/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ 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';
|
||||
|
|
@ -252,15 +251,11 @@ function makeStack(fixture: Fixture, opts?: StackOptions) {
|
|||
const config = configStub();
|
||||
if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs);
|
||||
const bootstrap: IBootstrapService = {
|
||||
...stubBootstrap(fixture.homeDir),
|
||||
...stubBootstrap(fixture.homeDir, {}, { agentFiles: opts?.explicitFiles }),
|
||||
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);
|
||||
|
|
@ -298,7 +293,6 @@ function makeStack(fixture: Fixture, opts?: StackOptions) {
|
|||
registry,
|
||||
);
|
||||
const explicitLoader = new ExplicitAgentProfileLoaderService(
|
||||
runtimeOptions,
|
||||
workspaceContext,
|
||||
bootstrap,
|
||||
hostFs,
|
||||
|
|
|
|||
|
|
@ -32,12 +32,11 @@ import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileReg
|
|||
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 { IBootstrapService, resolveHostArgs } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
|
|
@ -47,7 +46,6 @@ 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';
|
||||
|
|
@ -278,6 +276,7 @@ describe('workspace resource sharing (handler chain)', () => {
|
|||
_serviceBrand: undefined,
|
||||
homeDir,
|
||||
osHomeDir: homeDir,
|
||||
args: resolveHostArgs(undefined),
|
||||
scope: (name: string) => name,
|
||||
} as unknown as IBootstrapService),
|
||||
stubPair(IHostEnvironment, {
|
||||
|
|
@ -297,12 +296,6 @@ describe('workspace resource sharing (handler chain)', () => {
|
|||
} 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, {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ 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';
|
||||
|
|
@ -168,15 +167,10 @@ function makeHost(
|
|||
pluginReloadEmitter?: Emitter<ReloadSummary>,
|
||||
) {
|
||||
const config = configStub();
|
||||
const runtimeOptions = {
|
||||
_serviceBrand: undefined,
|
||||
explicitDirs,
|
||||
} as unknown as ISkillCatalogRuntimeOptions;
|
||||
const host = createScopedTestHost([
|
||||
stubPair(ISkillDiscovery, store),
|
||||
stubPair(IBootstrapService, bootstrapStub),
|
||||
stubPair(IBootstrapService, stubBootstrap('/home', {}, { skillDirs: explicitDirs })),
|
||||
stubPair(IConfigService, config),
|
||||
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
|
||||
stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)),
|
||||
stubPair(IHostFsWatchService, fsWatchStub()),
|
||||
]);
|
||||
|
|
@ -383,15 +377,11 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
} 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()),
|
||||
]);
|
||||
|
|
@ -423,15 +413,11 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
}
|
||||
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()),
|
||||
]);
|
||||
|
|
@ -646,9 +632,6 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
stubPair(ISkillDiscovery, new InMemorySkillDiscovery()),
|
||||
stubPair(IBootstrapService, bootstrapStub),
|
||||
stubPair(IConfigService, configStub()),
|
||||
stubPair(ISkillCatalogRuntimeOptions, {
|
||||
_serviceBrand: undefined,
|
||||
} as unknown as ISkillCatalogRuntimeOptions),
|
||||
stubPair(IPluginService, pluginStub()),
|
||||
stubPair(IHostFsWatchService, fsWatchStub()),
|
||||
]);
|
||||
|
|
@ -706,9 +689,6 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
stubPair(ISkillDiscovery, new InMemorySkillDiscovery()),
|
||||
stubPair(IBootstrapService, bootstrapStub),
|
||||
stubPair(IConfigService, configStub()),
|
||||
stubPair(ISkillCatalogRuntimeOptions, {
|
||||
_serviceBrand: undefined,
|
||||
} as unknown as ISkillCatalogRuntimeOptions),
|
||||
stubPair(IPluginService, pluginService),
|
||||
stubPair(IHostFsWatchService, fsWatchStub()),
|
||||
]);
|
||||
|
|
@ -765,9 +745,6 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
stubPair(ISkillDiscovery, store),
|
||||
stubPair(IBootstrapService, stubBootstrap(homeDir)),
|
||||
stubPair(IConfigService, configStub()),
|
||||
stubPair(ISkillCatalogRuntimeOptions, {
|
||||
_serviceBrand: undefined,
|
||||
} as unknown as ISkillCatalogRuntimeOptions),
|
||||
stubPair(IProviderService, stubProviderService()),
|
||||
stubPair(IHostFsWatchService, fsWatchStub()),
|
||||
]);
|
||||
|
|
@ -821,9 +798,6 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
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())),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ import {
|
|||
ISessionIndex,
|
||||
ISessionMetadata,
|
||||
ISessionSkillCatalog,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
ISkillDiscovery,
|
||||
IWorkspaceService,
|
||||
InMemorySkillCatalog,
|
||||
|
|
@ -342,11 +341,10 @@ async function listWorkspaceSkillsForRoot(
|
|||
const plugins = core.accessor.get(IPluginService);
|
||||
const config = core.accessor.get(IConfigService);
|
||||
await config.ready;
|
||||
const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions);
|
||||
const extraSkillDirs = config.get<ExtraSkillDirsConfig>(EXTRA_SKILL_DIRS_SECTION) ?? [];
|
||||
const mergeAllAvailableSkills =
|
||||
config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
|
||||
const explicitDirs = runtimeOptions.explicitDirs ?? [];
|
||||
const explicitDirs = bootstrap.args.skillDirs ?? [];
|
||||
const useExplicitDirs = explicitDirs.length > 0;
|
||||
const rootOptions = { mergeAllAvailableSkills };
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@
|
|||
|
||||
import {
|
||||
bootstrap,
|
||||
hostIdentitySeed,
|
||||
hostRequestHeadersSeed,
|
||||
IConfigService,
|
||||
IProviderDiscoveryService,
|
||||
IWorkspaceService,
|
||||
|
|
@ -18,7 +16,6 @@ import {
|
|||
resolveConfigPath,
|
||||
resolveKimiHome,
|
||||
resolveLoggingConfig,
|
||||
skillCatalogRuntimeOptionsSeed,
|
||||
type Scope,
|
||||
type ScopeSeed,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
|
@ -247,18 +244,17 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
homeDir,
|
||||
configPath,
|
||||
clientIdentity: opts.hostIdentity,
|
||||
args: {
|
||||
// Default host identity headers derived from `hostIdentity`: outbound
|
||||
// requests (model, WebSearch, registry refresh) carry the host
|
||||
// product's User-Agent + X-Msh-* set.
|
||||
requestHeaders: createKimiDefaultHeaders({ homeDir, ...opts.hostIdentity }),
|
||||
skillDirs: opts.skillDirs,
|
||||
displayName: opts.hostIdentity.displayName,
|
||||
replyStyleGuide: opts.hostIdentity.replyStyleGuide,
|
||||
},
|
||||
},
|
||||
[
|
||||
...logSeed(logging),
|
||||
// Default host identity headers derived from `hostIdentity`: outbound
|
||||
// requests (model, WebSearch, registry refresh) carry the host product's
|
||||
// User-Agent + X-Msh-* set. A host can still override individual headers
|
||||
// through `opts.seeds`, which are applied last (last seed wins).
|
||||
...hostRequestHeadersSeed(createKimiDefaultHeaders({ homeDir, ...opts.hostIdentity })),
|
||||
...skillCatalogRuntimeOptionsSeed(opts.skillDirs),
|
||||
...hostIdentitySeed(opts.hostIdentity),
|
||||
...(opts.seeds ?? []),
|
||||
],
|
||||
[...logSeed(logging), ...(opts.seeds ?? [])],
|
||||
);
|
||||
|
||||
// Attach the cloud telemetry appender BEFORE any session is created:
|
||||
|
|
|
|||
|
|
@ -13,12 +13,10 @@ import { pino } from 'pino';
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
hostRequestHeadersSeed,
|
||||
IBootstrapService,
|
||||
IFileSystemStorageService,
|
||||
IHostRequestHeaders,
|
||||
InMemoryStorageService,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
IOAuthToolkit,
|
||||
ITelemetryService,
|
||||
noopTelemetryService,
|
||||
|
|
@ -151,7 +149,7 @@ describe('server-v2 boot', () => {
|
|||
expect(defaults.headers['X-Msh-Platform']).toBe('test_platform');
|
||||
|
||||
// Restart on the same homeDir with a host-provided seed; it must win over
|
||||
// the default (the CLI passes full Kimi identity headers this way).
|
||||
// the default (a host can always re-seed the port with its own instance).
|
||||
await server.close();
|
||||
server = undefined;
|
||||
server = await startServer({
|
||||
|
|
@ -160,7 +158,7 @@ describe('server-v2 boot', () => {
|
|||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
seeds: hostRequestHeadersSeed({ 'User-Agent': 'custom-host/9.9' }),
|
||||
seeds: [[IHostRequestHeaders, { headers: { 'User-Agent': 'custom-host/9.9' } }]],
|
||||
});
|
||||
const overridden = server.core.accessor.get(IHostRequestHeaders);
|
||||
expect(overridden.headers['User-Agent']).toBe('custom-host/9.9');
|
||||
|
|
@ -176,11 +174,11 @@ describe('server-v2 boot', () => {
|
|||
logLevel: 'silent',
|
||||
skillDirs: ['/skills/explicit'],
|
||||
});
|
||||
expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toEqual([
|
||||
expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toEqual([
|
||||
'/skills/explicit',
|
||||
]);
|
||||
|
||||
// Without skillDirs the registered default carries no explicit dirs.
|
||||
// Without skillDirs the resolved args carry no explicit dirs.
|
||||
await server.close();
|
||||
server = undefined;
|
||||
server = await startServer({
|
||||
|
|
@ -190,7 +188,7 @@ describe('server-v2 boot', () => {
|
|||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined();
|
||||
expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { join } from 'node:path';
|
|||
import {
|
||||
IAgentLifecycleService,
|
||||
getLiveSessionById,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
activateSkillResultSchema,
|
||||
|
|
@ -313,7 +312,7 @@ describe('server-v2 /api/v1 skills', () => {
|
|||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
seeds: [[ISkillCatalogRuntimeOptions, { _serviceBrand: undefined, explicitDirs: [explicitDir] }]] as never,
|
||||
skillDirs: [explicitDir],
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export const configContract = {
|
|||
input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]),
|
||||
output: noResult,
|
||||
},
|
||||
replaceSections: {
|
||||
input: z.tuple([z.record(z.string(), z.unknown()), configTargetSchema.optional()]),
|
||||
output: noResult,
|
||||
},
|
||||
reload: { input: z.tuple([]), output: noResult },
|
||||
diagnostics: { input: z.tuple([]), output: z.array(configDiagnosticSchema) },
|
||||
} satisfies ServiceContract;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ export interface GlobalConfigFacade {
|
|||
value: unknown;
|
||||
target?: ConfigTargetLiteral;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Replace several domains in ONE atomic write (the engine's
|
||||
* `IConfigService.replaceSections`): a domain mapped to `undefined` is
|
||||
* cleared, domains absent from `sections` are left untouched.
|
||||
*/
|
||||
replaceSections(input: {
|
||||
sections: Record<string, unknown>;
|
||||
target?: ConfigTargetLiteral;
|
||||
}): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
diagnostics(): Promise<readonly ConfigDiagnostic[]>;
|
||||
}
|
||||
|
|
@ -306,7 +315,19 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr
|
|||
set: ({ domain, patch, target }) =>
|
||||
call('configService', 'set', [domain, patch, target]) as Promise<void>,
|
||||
replace: ({ domain, value, target }) =>
|
||||
call('configService', 'replace', [domain, value, target]) as Promise<void>,
|
||||
// `null` is the wire encoding of "clear this domain" — JSON
|
||||
// round-trips cannot carry `undefined` (see IConfigService.replace).
|
||||
call('configService', 'replace', [domain, value === undefined ? null : value, target]) as Promise<void>,
|
||||
replaceSections: ({ sections, target }) =>
|
||||
call('configService', 'replaceSections', [
|
||||
Object.fromEntries(
|
||||
Object.entries(sections).map(([domain, value]) => [
|
||||
domain,
|
||||
value === undefined ? null : value,
|
||||
]),
|
||||
),
|
||||
target,
|
||||
]) as Promise<void>,
|
||||
reload: () => call('configService', 'reload', []) as Promise<void>,
|
||||
diagnostics: () =>
|
||||
call('configService', 'diagnostics', []) as Promise<readonly ConfigDiagnostic[]>,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,39 @@ export function defineKlientConformance(
|
|||
expect(Array.isArray(await target.klient.global.config.diagnostics())).toBe(true);
|
||||
});
|
||||
|
||||
it('config replaceSections writes several domains and clears undefined ones', async () => {
|
||||
const config = target.klient.global.config;
|
||||
const beforeProviders = await config.inspect<Record<string, unknown>>('providers');
|
||||
const beforeModels = await config.inspect<Record<string, unknown>>('models');
|
||||
try {
|
||||
await config.replaceSections({
|
||||
sections: {
|
||||
providers: {
|
||||
...beforeProviders.userValue,
|
||||
'conf-provider': { type: 'openai', baseUrl: 'http://127.0.0.1:1', apiKey: 'k' },
|
||||
},
|
||||
models: {
|
||||
...beforeModels.userValue,
|
||||
'conf-provider/m1': { provider: 'conf-provider', model: 'm1', maxContextSize: 100 },
|
||||
},
|
||||
defaultModel: 'conf-provider/m1',
|
||||
},
|
||||
});
|
||||
expect((await config.inspect<string>('defaultModel')).userValue).toBe('conf-provider/m1');
|
||||
|
||||
// A domain mapped to `undefined` is cleared; domains absent from the
|
||||
// sections record are left untouched.
|
||||
await config.replaceSections({ sections: { defaultModel: undefined } });
|
||||
expect((await config.inspect<string>('defaultModel')).userValue).toBeUndefined();
|
||||
const providers = await config.inspect<Record<string, unknown>>('providers');
|
||||
expect(providers.userValue?.['conf-provider']).toBeDefined();
|
||||
} finally {
|
||||
await config.replaceSections({
|
||||
sections: { providers: beforeProviders.userValue, models: beforeModels.userValue },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('hostFs.home() returns the host home and recent roots', async () => {
|
||||
const home = await target.klient.global.hostFs.home();
|
||||
expect(home.home.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export {
|
|||
export { SDKRpcClientBase } from '#/rpc';
|
||||
export { KimiForCodingProvider } from '#/kimi-code-model-provider';
|
||||
export type { KimiForCodingProviderOptions } from '#/kimi-code-model-provider';
|
||||
export { removeProviderFromConfig } from '#/v2/config-mapper';
|
||||
|
||||
export {
|
||||
applyCatalogProvider,
|
||||
|
|
|
|||
|
|
@ -339,6 +339,24 @@ export class KimiHarness {
|
|||
return this.rpc.removeProvider(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether several config sections can be persisted as ONE atomic write
|
||||
* (see {@link replaceConfigSections}). False on the v1 harness.
|
||||
*/
|
||||
supportsAtomicSectionReplace(): boolean {
|
||||
return this.rpc.supportsAtomicSectionReplace();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace several top-level config sections in ONE atomic write: a section
|
||||
* mapped to `undefined` is cleared, absent sections are left untouched.
|
||||
* Replace semantics (unlike {@link setConfig}'s deep-merge), so staged
|
||||
* removals are expressed by the written record itself.
|
||||
*/
|
||||
async replaceConfigSections(sections: Record<string, unknown>): Promise<void> {
|
||||
return this.rpc.replaceConfigSections(sections);
|
||||
}
|
||||
|
||||
/** User-global MCP entries from `<KIMI_CODE_HOME>/mcp.json` only. */
|
||||
async listMcpServers(): Promise<readonly McpServerConfig[]> {
|
||||
return this.rpc.listGlobalMcpServers();
|
||||
|
|
|
|||
|
|
@ -294,6 +294,29 @@ export abstract class SDKRpcClientBase {
|
|||
return rpc.removeKimiProvider({ providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this client can persist several config sections as ONE atomic
|
||||
* write (see {@link replaceConfigSections}). v1 cannot — its config writes
|
||||
* are whole-document merges — so the default is false.
|
||||
*/
|
||||
supportsAtomicSectionReplace(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace several top-level config sections in ONE atomic write: a section
|
||||
* mapped to `undefined` is cleared, sections absent from the record are
|
||||
* left untouched. Unlike {@link setConfig} (a deep-merge that cannot
|
||||
* delete keys), this has replace semantics, so a staged removal can be
|
||||
* expressed by the written record itself.
|
||||
*/
|
||||
replaceConfigSections(_sections: Record<string, unknown>): Promise<void> {
|
||||
throw new KimiError(
|
||||
ErrorCodes.NOT_IMPLEMENTED,
|
||||
'This SDK client does not support atomic config section replacement.',
|
||||
);
|
||||
}
|
||||
|
||||
async listGlobalMcpServers(): Promise<readonly McpServerConfig[]> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.listGlobalMcpServers({});
|
||||
|
|
|
|||
|
|
@ -192,11 +192,9 @@ import {
|
|||
ISessionSecondaryModelWarningService,
|
||||
ISessionSkillCatalog,
|
||||
ISessionWorkspaceContext,
|
||||
ISkillCatalogRuntimeOptions,
|
||||
ISkillDiscovery,
|
||||
ITelemetryService,
|
||||
IWorkspaceAliases,
|
||||
hostRequestHeadersSeed,
|
||||
IWorkspaceDirs,
|
||||
ISessionLifecycleService,
|
||||
IWorkspaceLifecycleService,
|
||||
|
|
@ -222,7 +220,6 @@ import {
|
|||
resolveKimiHome,
|
||||
resolveLoggingConfig,
|
||||
resolvePrintBackgroundMode,
|
||||
skillCatalogRuntimeOptionsSeed,
|
||||
summarizeSkill,
|
||||
userRoots,
|
||||
type IAgentScopeHandle,
|
||||
|
|
@ -328,8 +325,7 @@ export interface SDKRpcClientV2Options {
|
|||
* Explicit skill directories for this process (v1's SDK `skillDirs` /
|
||||
* the CLI's `--skills-dir`): when non-empty, default user / project skill
|
||||
* discovery is skipped and these directories serve as the user skill
|
||||
* source. Seeded into the engine's app-scope
|
||||
* `ISkillCatalogRuntimeOptions`.
|
||||
* source. Passed into the engine through `BootstrapInput.args.skillDirs`.
|
||||
*/
|
||||
readonly skillDirs?: readonly string[];
|
||||
readonly telemetry?: TelemetryClient;
|
||||
|
|
@ -442,19 +438,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
homeDir: this.homeDir,
|
||||
configPath: this.configPath,
|
||||
clientIdentity: identity,
|
||||
args: {
|
||||
// Host identity headers for the engine's outbound requests (model,
|
||||
// WebSearch, registry refresh). Without them the managed vendors go
|
||||
// out with the SDK's default User-Agent and no X-Msh-* at all.
|
||||
requestHeaders: createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }),
|
||||
// `--skills-dir` (v1 parity): explicit skill dirs replace default
|
||||
// user / project discovery for every session this client hosts.
|
||||
skillDirs: options.skillDirs,
|
||||
},
|
||||
},
|
||||
[
|
||||
...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })),
|
||||
// Host identity headers for the engine's outbound requests (model,
|
||||
// WebSearch, registry refresh). Without this seed the managed vendors
|
||||
// go out with the SDK's default User-Agent and no X-Msh-* at all.
|
||||
...hostRequestHeadersSeed(
|
||||
createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }),
|
||||
),
|
||||
// `--skills-dir` (v1 parity): explicit skill dirs replace default
|
||||
// user / project discovery for every session this client hosts.
|
||||
...skillCatalogRuntimeOptionsSeed(options.skillDirs),
|
||||
],
|
||||
[...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))],
|
||||
);
|
||||
this.app = app;
|
||||
this.klient = createKlient({ scope: app });
|
||||
|
|
@ -562,7 +556,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
override async listWorkspaceSkills(workDir: string): Promise<readonly SkillSummary[]> {
|
||||
const bootstrapService = this.engineAccessor.get(IBootstrapService);
|
||||
const discovery = this.engineAccessor.get(ISkillDiscovery);
|
||||
const explicitDirs = this.engineAccessor.get(ISkillCatalogRuntimeOptions).explicitDirs ?? [];
|
||||
const explicitDirs = bootstrapService.args.skillDirs ?? [];
|
||||
const roots =
|
||||
explicitDirs.length > 0
|
||||
? await configuredRoots(explicitDirs, workDir, bootstrapService.osHomeDir, 'user')
|
||||
|
|
@ -671,9 +665,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
/**
|
||||
* v1's removal cascades: the provider entry, every model pointing at it,
|
||||
* and the default pointers when they dangle. The engine's own
|
||||
* `kosong.removeProvider` only clears the default-provider pointer, so
|
||||
* the full v1 cascade is computed from the user-layer values and applied
|
||||
* through the config facade (see `planProviderRemoval`).
|
||||
* `kosong.removeProvider` only clears the default-provider pointer, so the
|
||||
* full v1 cascade is computed from the user-layer values (see
|
||||
* `planProviderRemoval`) and persisted as ONE atomic multi-section replace —
|
||||
* the same single-write shape as v1's `removeKimiProvider`, so a process
|
||||
* exit can never leave the file in a halfway-cascaded state.
|
||||
*/
|
||||
override async removeProvider(providerId: string): Promise<KimiConfig> {
|
||||
await this.configReady;
|
||||
|
|
@ -690,17 +686,29 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
defaultProvider: defaultProvider.userValue,
|
||||
providerId,
|
||||
});
|
||||
await this.klient.global.config.replace({ domain: 'providers', value: plan.providers });
|
||||
await this.klient.global.config.replace({ domain: 'models', value: plan.models });
|
||||
const sections: Record<string, unknown> = {
|
||||
providers: plan.providers,
|
||||
models: plan.models,
|
||||
};
|
||||
if (plan.clearDefaultModel) {
|
||||
await this.klient.global.config.replace({ domain: 'defaultModel', value: undefined });
|
||||
sections['defaultModel'] = undefined;
|
||||
}
|
||||
if (plan.clearDefaultProvider) {
|
||||
await this.klient.global.config.replace({ domain: 'defaultProvider', value: undefined });
|
||||
sections['defaultProvider'] = undefined;
|
||||
}
|
||||
await this.klient.global.config.replaceSections({ sections });
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
override supportsAtomicSectionReplace(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override async replaceConfigSections(sections: Record<string, unknown>): Promise<void> {
|
||||
await this.configReady;
|
||||
await this.klient.global.config.replaceSections({ sections });
|
||||
}
|
||||
|
||||
override async listPlugins(): Promise<readonly PluginSummary[]> {
|
||||
return this.klient.global.plugins.list();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,3 +125,28 @@ export function planProviderRemoval(input: {
|
|||
clearDefaultProvider: input.defaultProvider === input.providerId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the v1 remove-provider cascade to a whole `KimiConfig` in memory (no
|
||||
* persistence): drop the provider entry, every model pointing at it, and the
|
||||
* default pointers when they dangle. Hosts that stage a removal and fold it
|
||||
* into a later atomic write (instead of persisting it immediately) build on
|
||||
* this — the same role the v2 engine's `shapeWithoutProvider` plays for its
|
||||
* own refresh path.
|
||||
*/
|
||||
export function removeProviderFromConfig(config: KimiConfig, providerId: string): KimiConfig {
|
||||
const plan = planProviderRemoval({
|
||||
providers: config.providers as Record<string, unknown> | undefined,
|
||||
models: config.models as Record<string, Record<string, unknown>> | undefined,
|
||||
defaultModel: config.defaultModel,
|
||||
defaultProvider: config.defaultProvider,
|
||||
providerId,
|
||||
});
|
||||
return {
|
||||
...config,
|
||||
providers: plan.providers as KimiConfig['providers'],
|
||||
models: plan.models as KimiConfig['models'],
|
||||
defaultModel: plan.clearDefaultModel ? undefined : config.defaultModel,
|
||||
defaultProvider: plan.clearDefaultProvider ? undefined : config.defaultProvider,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ import {
|
|||
ErrorCodes,
|
||||
KimiError,
|
||||
KimiHarness,
|
||||
SDKRpcClientV2,
|
||||
type Event,
|
||||
removeProviderFromConfig,
|
||||
SDKRpcClientV2,
|
||||
type KimiConfig,
|
||||
} from '#/index';
|
||||
import { foldAgentWireReplay } from '#/v2/resume-replay';
|
||||
import {
|
||||
|
|
@ -511,6 +513,60 @@ key = "${titleOAuthRef.key}"
|
|||
}
|
||||
});
|
||||
|
||||
it('persists removeProvider as one atomic cascade (providers, models, defaults)', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
try {
|
||||
await harness.setConfig({
|
||||
providers: {
|
||||
a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' },
|
||||
b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' },
|
||||
},
|
||||
models: {
|
||||
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
|
||||
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
|
||||
},
|
||||
defaultModel: 'b/m1',
|
||||
defaultProvider: 'b',
|
||||
});
|
||||
const next = await harness.removeProvider('b');
|
||||
expect(next.providers['b']).toBeUndefined();
|
||||
expect(next.providers['a']).toBeDefined();
|
||||
expect(next.models?.['b/m1']).toBeUndefined();
|
||||
expect(next.models?.['a/m1']).toBeDefined();
|
||||
expect(next.defaultModel).toBeUndefined();
|
||||
expect(next.defaultProvider).toBeUndefined();
|
||||
// A fresh read from disk sees the same state — the cascade landed as a
|
||||
// single atomic write, never a halfway-removed intermediate.
|
||||
const reread = await harness.getConfig({ reload: true });
|
||||
expect(reread.providers['b']).toBeUndefined();
|
||||
expect(reread.models?.['b/m1']).toBeUndefined();
|
||||
expect(reread.defaultModel).toBeUndefined();
|
||||
expect(reread.defaultProvider).toBeUndefined();
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces config sections atomically and clears undefined sections', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
try {
|
||||
expect(harness.supportsAtomicSectionReplace()).toBe(true);
|
||||
await harness.setConfig({
|
||||
providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' } },
|
||||
models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 } },
|
||||
defaultModel: 'a/m1',
|
||||
});
|
||||
await harness.replaceConfigSections({ defaultModel: undefined });
|
||||
const next = await harness.getConfig({ reload: true });
|
||||
expect(next.defaultModel).toBeUndefined();
|
||||
// Sections absent from the write stay untouched.
|
||||
expect(next.providers['a']).toBeDefined();
|
||||
expect(next.models?.['a/m1']).toBeDefined();
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails loudly with not_implemented for methods not yet migrated', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
try {
|
||||
|
|
@ -699,8 +755,58 @@ describe('SDKRpcClientV2 engine telemetry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
async function writeSkill(dir: string, name: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
describe('removeProviderFromConfig', () => {
|
||||
it('drops the provider, its models and dangling default pointers without mutating the input', () => {
|
||||
const config = {
|
||||
providers: {
|
||||
a: { type: 'openai', baseUrl: 'https://a.example.test/v1' },
|
||||
b: { type: 'openai', baseUrl: 'https://b.example.test/v1' },
|
||||
},
|
||||
models: {
|
||||
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
|
||||
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
|
||||
'my-b': { provider: 'b', model: 'm1', maxContextSize: 100 },
|
||||
},
|
||||
defaultModel: 'my-b',
|
||||
defaultProvider: 'b',
|
||||
} as unknown as KimiConfig;
|
||||
|
||||
const next = removeProviderFromConfig(config, 'b');
|
||||
|
||||
expect(Object.keys(next.providers)).toEqual(['a']);
|
||||
expect(Object.keys(next.models ?? {})).toEqual(['a/m1']);
|
||||
expect(next.defaultModel).toBeUndefined();
|
||||
expect(next.defaultProvider).toBeUndefined();
|
||||
// The input config is left untouched (the staging host threads the copy).
|
||||
expect(config.providers['b']).toBeDefined();
|
||||
expect(config.models?.['b/m1']).toBeDefined();
|
||||
expect(config.defaultModel).toBe('my-b');
|
||||
});
|
||||
|
||||
it('keeps the default pointers when they do not dangle', () => {
|
||||
const config = {
|
||||
providers: {
|
||||
a: { type: 'openai' },
|
||||
b: { type: 'openai' },
|
||||
},
|
||||
models: {
|
||||
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
|
||||
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
|
||||
},
|
||||
defaultModel: 'a/m1',
|
||||
defaultProvider: 'a',
|
||||
} as unknown as KimiConfig;
|
||||
|
||||
const next = removeProviderFromConfig(config, 'b');
|
||||
|
||||
expect(Object.keys(next.providers)).toEqual(['a']);
|
||||
expect(Object.keys(next.models ?? {})).toEqual(['a/m1']);
|
||||
expect(next.defaultModel).toBe('a/m1');
|
||||
expect(next.defaultProvider).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
async function writeSkill(dir: string, name: string): Promise<void> { await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
join(dir, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: Skill ${name} for the escape-hatch test\n---\n\nBody of ${name}.\n`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue