chore: fix lint issues across packages

- remove unused imports and variables
- add void to floating promises
- drop redundant return-await
- bind unbound method references
- replace == null with strict null/undefined checks
- add explicit sort comparators and Array.from helpers
- rename background_tasks capability to tasks
- migrate event sink to event bus in tests
This commit is contained in:
haozhe.yang 2026-07-07 22:51:27 +08:00
parent 5b57a3297a
commit 79ffb7b347
98 changed files with 446 additions and 336 deletions

View file

@ -202,7 +202,7 @@ function describeApproval(display: ToolInputDisplay, action: string): string {
return `search: ${display.query ?? ''}`.trim();
case 'todo_list':
return `update todo list (${String(display.items?.length ?? 0)} items)`;
case 'background_task':
case 'task':
return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${
display.description ?? ''
}`.trim();
@ -334,7 +334,7 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
return [];
case 'todo_list':
return [];
case 'background_task':
case 'task':
return [];
default:
return [];

View file

@ -10,7 +10,7 @@
* (default) rewrite all consumer files (src + test) EXCEPT src/index.ts
* --only=<reldir> limit consumer rewriting to one barrel, e.g. app/event
* --entry regenerate src/index.ts only (no consumer rewriting)
* --delete-barrels delete every domain barrel (src/**/index.ts except entry)
* --delete-barrels delete every domain barrel (per-domain src index.ts except entry)
* --list-registers print the top-level register* files (coverage set)
* --verify-coverage exit non-zero if any register file is unreachable from entry
* --dry-run report planned edits without writing
@ -65,7 +65,7 @@ function resolveName(barrel, name) {
break;
}
}
if (leafName == null) leafName = (sym && sym.getName()) || name;
if (leafName === null) leafName = (sym && sym.getName()) || name;
return { leafFile: leaf.getFilePath(), leafName };
}
@ -118,7 +118,7 @@ function allLeavesUnderDir(dirAbs) {
}
};
walk(dirAbs);
return out.sort();
return out.sort((a, b) => a.localeCompare(b));
}
// ---------------------------------------------------------------------------
@ -128,7 +128,7 @@ function rewriteConsumerFile(sf, onlyBarrelPath) {
const report = { imports: 0, exports: 0, manuals: [], sideEffects: 0 };
// Imports.
for (const decl of [...sf.getImportDeclarations()]) {
for (const decl of sf.getImportDeclarations()) {
const barrel = barrelOfDecl(decl);
if (!barrel) continue;
if (onlyBarrelPath && barrel.getFilePath() !== onlyBarrelPath) continue;
@ -187,7 +187,7 @@ function rewriteConsumerFile(sf, onlyBarrelPath) {
}
// Exports.
for (const decl of [...sf.getExportDeclarations()]) {
for (const decl of sf.getExportDeclarations()) {
const barrel = barrelOfDecl(decl);
if (!barrel) continue;
if (onlyBarrelPath && barrel.getFilePath() !== onlyBarrelPath) continue;

View file

@ -19,8 +19,6 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = join(__dirname, '..', 'src');
const SCOPE_OF = ['App', 'Session', 'Agent'];
const SCOPE_DIRS = new Set(['app', 'session', 'agent']);
/** Resolve a `src/`-relative file path to its domain, skipping the scope tier. */

View file

@ -1039,7 +1039,7 @@ export function summarize(graph: Graph): string {
const byKind = new Map<string, number>();
for (const e of graph.edges) byKind.set(e.kind, (byKind.get(e.kind) ?? 0) + 1);
const kindSummary = [...byKind.entries()]
.sort()
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, n]) => `${k}=${n}`)
.join(' ');
return `services=${graph.services.length} edges=${graph.edges.length} ${kindSummary}`;

View file

@ -16,7 +16,7 @@ export function App(): JSX.Element {
const queryParams = useMemo(() => readQueryParams(window.location.search), []);
const domains = useMemo(
() => [...new Set(graph.services.map((s) => s.domain))].sort(),
() => [...new Set(graph.services.map((s) => s.domain))].sort((a, b) => a.localeCompare(b)),
[],
);

View file

@ -73,7 +73,7 @@ try {
});
} catch (err) {
const code = err && typeof err === 'object' && 'status' in err ? err.status : 'unknown';
log(`tsc exited ${code} (non-fatal; declarations are still emitted)`);
log(`tsc exited ${String(code)} (non-fatal; declarations are still emitted)`);
}
// 2. Detect impl files + registered class names (AST only).

View file

@ -138,7 +138,7 @@ export class AsyncEmitter<T extends IWaitUntil> extends Emitter<T> {
continue;
}
Object.freeze(thenables);
void Object.freeze(thenables);
const settled = await Promise.allSettled(thenables);
for (const result of settled) {
if (result.status === 'rejected') {

View file

@ -575,15 +575,6 @@ function compactionCancelledReason(active: ActiveCompaction | null): Error {
return error;
}
function isTodoItem(value: unknown): value is TodoItem {
if (value === null || typeof value !== 'object') return false;
const item = value as { title?: unknown; status?: unknown };
return (
typeof item.title === 'string' &&
(item.status === 'pending' || item.status === 'in_progress' || item.status === 'done')
);
}
// Construct eagerly (not delayed): the service registers turn and loop hooks
// (onLaunched / beforeStep / afterStep / onError) that drive auto
// compaction. With delayed instantiation the eager `accessor.get(IAgentFullCompactionService)`

View file

@ -112,7 +112,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
signal?: AbortSignal,
): Promise<LLMRequestFinish> {
signal?.throwIfAborted();
return await this.requestWithRetry(overrides, onPart, signal);
return this.requestWithRetry(overrides, onPart, signal);
}
private async requestWithRetry(
@ -170,7 +170,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
overrides,
attempt === 1 ? undefined : { attempt: `${String(attempt)}/${String(maxAttempts)}` },
);
return await this.runRequest(request, onPart, signal);
return this.runRequest(request, onPart, signal);
}
private logRequestFailure(
@ -286,7 +286,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
model: request.model.name,
...request.logFields,
});
return await run(true);
return run(true);
}
}

View file

@ -300,7 +300,7 @@ export class AgentRPCService implements IAgentRPCService {
}
async activateSkill(payload: ActivateSkillPayload): Promise<void> {
this.skills.activate(payload);
void this.skills.activate(payload);
await this.updatePromptMetadata(promptMetadataTextFromSkill(payload));
}

View file

@ -467,7 +467,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
entry.foregroundSignalCleanup = undefined;
this.applyDetachTimeout(entry);
try {
const onDetach = entry.onDetachFn ?? entry.task?.onDetach;
const onDetach =
entry.onDetachFn ??
(entry.task === undefined ? undefined : entry.task.onDetach?.bind(entry.task));
onDetach?.();
} catch {
/* detach has already succeeded; hooks must not make RPC fail */
@ -546,7 +548,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (!graceful) {
try {
const forceStop = entry.forceStopFn ?? entry.task?.forceStop;
const forceStop =
entry.forceStopFn ??
(entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task));
await forceStop?.();
} catch {
/* best effort */

View file

@ -176,7 +176,7 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco
restoredRecords !== undefined &&
this.log !== undefined
) {
this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords);
void this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords);
await this.log.flush();
}
if (completed) {

View file

@ -20,9 +20,6 @@ import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider';
/** Reserved key for the env-driven synthetic model alias. */
export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__';
const ALLOWED_TYPES = ['kimi', 'anthropic', 'openai'] as const;
type EnvProviderType = (typeof ALLOWED_TYPES)[number];
/** Default context window (256K) used when KIMI_MODEL_MAX_CONTEXT_SIZE is unset. */
const DEFAULT_MAX_CONTEXT_SIZE = 262144;

View file

@ -212,9 +212,9 @@ export class ModelImpl implements Model {
`Model "${this.id}" (protocol=${this.protocol}) does not support video upload`,
);
}
const uploadVideo = provider.uploadVideo;
const uploadVideo = provider.uploadVideo.bind(provider);
return this.runWithAuthRefresh((auth) =>
uploadVideo.call(provider, input, { signal: options?.signal, auth }),
uploadVideo(input, { signal: options?.signal, auth }),
);
}

View file

@ -43,7 +43,6 @@ import {
type ProviderCredentialState,
type RefreshProviderModelsOptions,
IModelCatalogService,
modelIdsForProvider,
toProtocolModel,
toProtocolProvider,
} from './modelCatalog';
@ -186,8 +185,8 @@ export class ModelCatalogService implements IModelCatalogService {
if (patch.defaultModel !== undefined) {
await this.config.set(DEFAULT_MODEL_SECTION, patch.defaultModel);
}
if (patch.defaultThinking !== undefined) {
await this.config.set(DEFAULT_THINKING_SECTION, patch.defaultThinking);
if (patch['defaultThinking'] !== undefined) {
await this.config.set(DEFAULT_THINKING_SECTION, patch['defaultThinking']);
}
return this.readUserConfigShape();
}

View file

@ -1,6 +1,6 @@
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types';
export interface BearerTokenProvider {
interface BearerTokenProvider {
getAccessToken(options?: { readonly force?: boolean | undefined }): Promise<string>;
}

View file

@ -38,7 +38,7 @@ export interface ComparisonOp {
}
export type QueryFilter = {
readonly [field: string]: unknown | ComparisonOp;
readonly [field: string]: unknown;
};
export interface IQuery<T> {

View file

@ -759,7 +759,7 @@ function parseRgJsonOutput(
}
}
for (const p of [...fileBuf.keys()]) {
for (const p of fileBuf.keys()) {
finalize(p);
}

View file

@ -294,7 +294,7 @@ export class WireService extends Disposable implements IWireService {
});
return;
}
const dehydrate = model.blobs.dehydrate;
const dehydrate = model.blobs.dehydrate.bind(model.blobs);
const transform: PartsTransformer = (parts) =>
this.blobService!.offloadParts(
parts as readonly ContentPart[],

View file

@ -182,7 +182,6 @@ describe('AgentLifecycleService', () => {
ix.stub(ILogService, noopLog);
ix.stub(IAgentPluginService, {
_serviceBrand: undefined,
appendFreshSessionStartReminder: async () => {},
});
ix.stub(IAgentToolRegistryService, {
_serviceBrand: undefined,

View file

@ -11,7 +11,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool/toolContract';
import { LocalFetchURLProvider } from '#/app/web/providers/local-fetch-url';
import { FetchURLTool, type UrlFetcher, type UrlFetchResult } from '#/app/web/tools/fetch-url';
import { FetchURLTool } from '#/app/web/tools/fetch-url';
import type { UrlFetcher, UrlFetchResult } from '#/app/web/tools/fetch-url-types';
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
@ -70,7 +71,10 @@ describe('FetchURLTool abort signal', () => {
const result = await execute(tool, 'https://example.com', controller.signal);
expect(result.isError).toBe(true);
expect(String(result.output)).toContain('boom');
if (typeof result.output !== 'string') {
throw new Error('expected string error output');
}
expect(result.output).toContain('boom');
});
});

View file

@ -5,7 +5,7 @@
* storage is exercised.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { resolveKimiCodeRuntimeAuth } from '@moonshot-ai/kimi-code-oauth';
import { DisposableStore } from '#/_base/di/lifecycle';
@ -42,7 +42,7 @@ const deviceAuth = {
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
interface FakeToolkit {
readonly login: ReturnType<typeof vi.fn>;
readonly login: Mock<(...args: any[]) => any>;
readonly logout: ReturnType<typeof vi.fn>;
readonly getCachedAccessToken: ReturnType<typeof vi.fn>;
readonly tokenProvider: ReturnType<typeof vi.fn>;
@ -109,7 +109,7 @@ describe('OAuthService', () => {
});
events = [];
toolkit = {
login: vi.fn(),
login: vi.fn<(...args: any[]) => any>(),
logout: vi.fn().mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true }),
getCachedAccessToken: vi.fn().mockResolvedValue(undefined),
tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }),
@ -185,7 +185,7 @@ describe('OAuthService', () => {
it('startLogin resolves a device-code flow and flips to authenticated on success', async () => {
stubManagedModelsFetch();
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
@ -210,7 +210,7 @@ describe('OAuthService', () => {
it('provisions the managed provider through the provider service after login', async () => {
stubManagedModelsFetch();
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
@ -232,7 +232,7 @@ describe('OAuthService', () => {
it('startLogin resolves a default oauth ref for the managed provider without oauth config', async () => {
providers[OAUTH_PROVIDER] = { type: 'kimi', baseUrl: 'https://api.example.com' };
stubManagedModelsFetch();
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
@ -312,7 +312,7 @@ describe('OAuthService', () => {
it('keeps a device-code login authenticated when model fetch is unavailable after authorization', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network disabled in test'));
vi.stubGlobal('fetch', fetchMock);
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
@ -329,7 +329,7 @@ describe('OAuthService', () => {
it('refreshes managed models and sets the default model after a device-code login succeeds', async () => {
const fetchMock = stubManagedModelsFetch();
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
@ -356,7 +356,7 @@ describe('OAuthService', () => {
});
it('keeps an in-flight OAuth flow alive when unrelated providers change', async () => {
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return new Promise(() => { });
});
@ -370,7 +370,7 @@ describe('OAuthService', () => {
});
it('aborts an in-flight OAuth flow when its provider is removed from config', async () => {
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return new Promise(() => { });
});
@ -385,7 +385,7 @@ describe('OAuthService', () => {
it('cancelLogin aborts a pending flow and marks it cancelled', async () => {
let capturedSignal: AbortSignal | undefined;
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
capturedSignal = options.signal;
options.onDeviceCode(deviceAuth);
return new Promise(() => { });
@ -400,7 +400,7 @@ describe('OAuthService', () => {
});
it('logout delegates to the toolkit and clears any pending flow', async () => {
toolkit.login.mockImplementation(async (_provider, options) => {
toolkit.login.mockImplementation((_provider, options) => {
options.onDeviceCode(deviceAuth);
return new Promise(() => { });
});
@ -785,7 +785,7 @@ describe('AuthSummaryService', () => {
type: 'kimi',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
};
oauthStatus.mockImplementation(async (name: string) => {
oauthStatus.mockImplementation((name: string) => {
if (name === OTHER_OAUTH) throw new Error('No OAuth manager configured');
return { loggedIn: true, provider: name };
});

View file

@ -10,16 +10,17 @@
import { toDisposable } from '#/_base/di/lifecycle';
import type { ServiceRegistration } from '#/_base/di/test';
import { createHooks } from '#/hooks';
import type { Hooks } from '#/hooks';
import { buildContextCompactionShape } from '#/agent/contextMemory/compactionHandoff';
import {
IAgentContextMemoryService,
type ContextCompactionInput,
type ContextCompactionResult,
} from '#/agent/contextMemory/contextMemory';
import { computeUndoCut } from '#/agent/contextMemory/contextOps';
import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps';
import { ensureMessageId } from '#/agent/contextMemory/messageId';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
/**
@ -52,21 +53,22 @@ export interface StubContextMemory extends IAgentContextMemoryService {
* and fires `onSpliced`, mirroring `AgentContextMemoryService.applySplice` enough
* for collaborators (e.g. `DynamicInjectorService`) to react to splices.
*/
export function stubContextMemory(): StubContextMemory {
function publishSplice(
eventBus: IEventBus | undefined,
input: {
start: number;
deleteCount: number;
messages: readonly ContextMessage[];
tokens?: number;
},
): void {
eventBus?.publish({ type: 'context.spliced', ...input });
}
export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
const messages: ContextMessage[] = [];
const hooks = {
onSpliced: createHooks(['onSpliced'])['onSpliced'],
} as unknown as Hooks<{
onSpliced: {
start: number;
deleteCount: number;
messages: ContextMessage[];
tokens?: number;
};
}>;
return {
_serviceBrand: undefined,
hooks,
get messages() {
return messages;
},
@ -75,20 +77,20 @@ export function stubContextMemory(): StubContextMemory {
const stamped = inserted.map(ensureMessageId);
const start = messages.length;
messages.push(...stamped);
void hooks.onSpliced.run({ start, deleteCount: 0, messages: [...stamped] });
publishSplice(eventBus, { start, deleteCount: 0, messages: [...stamped] });
},
clear: () => {
const deleteCount = messages.length;
if (deleteCount === 0) return;
messages.splice(0, deleteCount);
void hooks.onSpliced.run({ start: 0, deleteCount, messages: [] });
publishSplice(eventBus, { start: 0, deleteCount, messages: [] });
},
undo: (count) => {
const cut = computeUndoCut(messages, count);
if (cut.cutIndex >= 0 && cut.removedCount >= count) {
const deleteCount = messages.length - cut.cutIndex;
messages.splice(cut.cutIndex, deleteCount);
void hooks.onSpliced.run({ start: cut.cutIndex, deleteCount, messages: [] });
publishSplice(eventBus, { start: cut.cutIndex, deleteCount, messages: [] });
}
return cut;
},
@ -96,7 +98,7 @@ export function stubContextMemory(): StubContextMemory {
const shape = buildContextCompactionShape(messages, input);
const previousLength = messages.length;
messages.splice(0, previousLength, ...shape.messages);
void hooks.onSpliced.run({
publishSplice(eventBus, {
start: 0,
deleteCount: previousLength,
messages: [...shape.messages],
@ -109,7 +111,7 @@ export function stubContextMemory(): StubContextMemory {
splice: (start, deleteCount, inserted, tokens) => {
const stamped = inserted.map(ensureMessageId);
messages.splice(start, deleteCount, ...stamped);
void hooks.onSpliced.run({
publishSplice(eventBus, {
start,
deleteCount,
messages: [...stamped],
@ -119,6 +121,46 @@ export function stubContextMemory(): StubContextMemory {
};
}
/**
* DI-constructible variant of {@link stubContextMemory}: publishes
* `context.spliced` to the Agent-scope {@link IEventBus} so collaborators
* (e.g. `AgentContextInjectorService`) react to splices exactly as they do
* against the real `AgentContextMemoryService`.
*/
class StubContextMemoryService implements IAgentContextMemoryService {
declare readonly _serviceBrand: undefined;
private readonly impl: StubContextMemory;
constructor(@IEventBus eventBus: IEventBus) {
this.impl = stubContextMemory(eventBus);
}
get messages(): readonly ContextMessage[] {
return this.impl.messages;
}
get(): readonly ContextMessage[] {
return this.impl.get();
}
append(...messages: readonly ContextMessage[]): void {
this.impl.append(...messages);
}
clear(): void {
this.impl.clear();
}
undo(count: number): UndoCut {
return this.impl.undo(count);
}
applyCompaction(input: ContextCompactionInput): ContextCompactionResult {
return this.impl.applyCompaction(input);
}
splice(
start: number,
deleteCount: number,
messages: readonly ContextMessage[],
tokens?: number,
): void {
this.impl.splice(start, deleteCount, messages, tokens);
}
}
/**
* Register the default collaborators consumed by `AgentContextMemoryService`
* (`IAgentWireRecordService`) and an in-memory `IAgentContextMemoryService`.
@ -127,5 +169,6 @@ export function stubContextMemory(): StubContextMemory {
*/
export function registerContextMemoryServices(reg: ServiceRegistration): void {
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
reg.define(IEventBus, EventBusService);
reg.define(IAgentContextMemoryService, StubContextMemoryService);
}

View file

@ -21,7 +21,6 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentTurnService, type Turn } from '#/agent/turn/turn';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubWireRecord } from '../contextMemory/stubs';
import { stubLog } from '../log/stubs';
@ -87,7 +86,6 @@ describe('SessionCronService', () => {
undo: () => 0,
clear: () => {},
});
ix.stub(IAgentEventSinkService, { emit: () => {}, on: () => ({ dispose: () => {} }) });
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentTurnService, turnService);
ix.stub(ITelemetryService, { track: () => {} });

View file

@ -310,12 +310,12 @@ describe('CronCreateTool', () => {
harness.store.add({ cron: '*/5 * * * *', prompt: `seed-${i}`, recurring: true }, harness.now());
}
const first = await tool.resolveExecution({
const first = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'first',
recurring: true,
});
const second = await tool.resolveExecution({
const second = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'second',
recurring: true,
@ -375,7 +375,7 @@ describe('CronCreateTool', () => {
it('uses the execution-time clock for createdAt', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(false, harness.cron);
const execution = await tool.resolveExecution({
const execution = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'delayed approval',
recurring: true,
@ -396,17 +396,17 @@ describe('CronCreateTool', () => {
const harness = createToolHarness();
const tool = new CronCreateTool(false, harness.cron);
const a = await tool.resolveExecution({
const a = tool.resolveExecution({
cron: '*/5\n* * * *',
prompt: 'same',
recurring: true,
});
const b = await tool.resolveExecution({
const b = tool.resolveExecution({
cron: '0 9 * * *',
prompt: 'same',
recurring: true,
});
const c = await tool.resolveExecution({
const c = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'different',
recurring: true,

View file

@ -30,17 +30,14 @@ class Service2 implements IService2 {
}
class Service1Consumer {
constructor(@IService1 service1: IService1) {
expect(service1).toBeInstanceOf(Service1);
expect(service1.c).toBe(1);
}
constructor(@IService1 readonly service1: IService1) {}
}
class Target2Dep {
constructor(@IService1 service1: IService1, @IService2 service2: IService2) {
expect(service1).toBeInstanceOf(Service1);
expect(service2).toBeInstanceOf(Service2);
}
constructor(
@IService1 readonly service1: IService1,
@IService2 readonly service2: IService2,
) {}
}
describe('ServiceCollection', () => {
@ -71,12 +68,16 @@ describe('ServiceCollection', () => {
collection.set(IService1, new Service1());
const service = new InstantiationService(collection);
service.createInstance(Service1Consumer);
const consumer = service.createInstance(Service1Consumer);
expect(consumer.service1).toBeInstanceOf(Service1);
expect(consumer.service1.c).toBe(1);
// add IService2 AFTER the InstantiationService was built
collection.set(IService2, new Service2());
service.createInstance(Target2Dep);
const target2 = service.createInstance(Target2Dep);
expect(target2.service1).toBeInstanceOf(Service1);
expect(target2.service2).toBeInstanceOf(Service2);
service.invokeFunction((a) => {
expect(a.get(IService1)).toBeInstanceOf(Service1);
expect(a.get(IService2)).toBeInstanceOf(Service2);

View file

@ -1,14 +1,15 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import { IAgentGoalService } from '#/agent/goal/goal';
import { type AgentGoalService } from '#/agent/goal/goalService';
import { IAgentLoopService, type AfterStepContext } from '#/agent/loop/loop';
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn/turn';
import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord/wireRecord';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import type { TokenUsage } from '#/app/llmProtocol/usage';
import { ErrorCodes } from '#/errors';
import { ErrorCodes, toKimiErrorPayload } from '#/errors';
import {
InMemoryWireRecordPersistence,
@ -23,10 +24,8 @@ import { stubLoopWithHooks, stubTurn, type StubTurn } from '../turn/stubs';
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
type GoalRecord = Extract<PersistedWireRecord, { type: `goal.${string}` }>;
type AgentEvent = Parameters<IAgentEventSinkService['emit']>[0];
type AgentEvent = DomainEvent;
type GoalUpdatedEvent = Extract<AgentEvent, { type: 'goal.updated' }>;
type GoalSnapshot = NonNullable<ReturnType<IAgentGoalService['getGoal']>['goal']>;
type GoalChange = GoalUpdatedEvent['change'];
const zeroUsage: TokenUsage = {
inputCacheRead: 0,
@ -94,12 +93,19 @@ async function runStepUsageHooks(
return goals.getGoal().goal?.budget.overBudget === true;
}
async function endTurn(
turnService: IAgentTurnService,
function endTurn(
eventBus: IEventBus,
turn: Turn,
result: TurnResult = { reason: 'completed' },
): Promise<void> {
await turnService.hooks.onEnded.run({ turn, result });
): void {
const error = result.error !== undefined ? toKimiErrorPayload(result.error) : undefined;
eventBus.publish({
type: 'turn.ended',
turnId: turn.id,
reason: result.reason,
error,
durationMs: 0,
});
}
describe('AgentGoalService', () => {
@ -107,11 +113,7 @@ describe('AgentGoalService', () => {
let context: IAgentContextMemoryService;
let goals: GoalServiceTestManager;
let records: PersistedWireRecord[];
let events: Array<{
readonly type: string;
readonly snapshot?: GoalSnapshot | null;
readonly change?: GoalChange;
}>;
let events: GoalUpdatedEvent[];
let telemetry: TelemetryRecord[];
beforeEach(() => {
@ -125,8 +127,8 @@ describe('AgentGoalService', () => {
context = ctx.get(IAgentContextMemoryService);
goals = ctx.get(IAgentGoalService) as GoalServiceTestManager;
records = persistence.records;
const eventSink = ctx.get(IAgentEventSinkService);
eventSink.on((event) => {
const eventBus = ctx.get(IEventBus);
eventBus.subscribe((event) => {
if (event.type === 'goal.updated') events.push(event);
});
});
@ -537,7 +539,7 @@ describe('AgentGoalService core workflow hooks', () => {
let goals: IAgentGoalService;
let turnService: StubTurn;
let loopService: IAgentLoopService;
let eventSink: IAgentEventSinkService;
let eventBus: IEventBus;
beforeEach(() => {
turnService = stubTurn({ hasActiveTurn: true });
@ -548,7 +550,7 @@ describe('AgentGoalService core workflow hooks', () => {
);
context = ctx.get(IAgentContextMemoryService);
goals = ctx.get(IAgentGoalService);
eventSink = ctx.get(IAgentEventSinkService);
eventBus = ctx.get(IEventBus);
});
afterEach(async () => {
@ -559,9 +561,9 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(1);
await turnService.hooks.onLaunched.run({ turn });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
await runGoalStep(loopService, turn);
await endTurn(turnService, turn);
endTurn(eventBus, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'active',
@ -580,9 +582,9 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.setBudgetLimits({ budgetLimits: { turnBudget: 1 } }, 'model');
const turn = makeTurn(11);
await turnService.hooks.onLaunched.run({ turn });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
await runGoalStep(loopService, turn);
await endTurn(turnService, turn);
endTurn(eventBus, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
@ -597,7 +599,7 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 7 } }, 'model');
const turn = turnService.launch();
await turnService.hooks.onLaunched.run({ turn });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
expect(
await runStepUsageHooks(loopService, goals, turn, {
@ -643,11 +645,11 @@ describe('AgentGoalService core workflow hooks', () => {
it('continues after creating a goal mid-turn without counting the starter turn', async () => {
const turn = makeTurn(2);
await turnService.hooks.onLaunched.run({ turn });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
await runGoalStep(loopService, turn);
await goals.createGoal({ objective: 'finish the task' }, 'model');
await endTurn(turnService, turn);
endTurn(eventBus, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'active',
@ -660,7 +662,7 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(3);
await turnService.hooks.onLaunched.run({ turn });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
const step = {
turnId: turn.id,
step: 1,
@ -678,7 +680,7 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.markComplete({}, 'model');
await loopService.hooks.afterStep.run(afterStep);
await endTurn(turnService, turn);
endTurn(eventBus, turn);
expect(afterStep.continue).toBe(true);
expect(goals.getGoal().goal).toBeNull();
@ -693,8 +695,8 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(4);
await turnService.hooks.onLaunched.run({ turn });
await endTurn(turnService, turn, { reason: 'failed', error: new Error('boom') });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
endTurn(eventBus, turn, { reason: 'failed', error: new Error('boom') });
expect(goals.getGoal().goal).toMatchObject({
status: 'paused',
@ -707,8 +709,8 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(5);
await turnService.hooks.onLaunched.run({ turn });
await endTurn(turnService, turn, { reason: 'blocked' });
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
endTurn(eventBus, turn, { reason: 'blocked' });
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',

View file

@ -265,7 +265,7 @@ interface ResumeStateSnapshot {
readonly config: {
readonly cwd: string;
readonly activeToolNames: readonly string[] | undefined;
readonly provider: ProviderConfig | undefined;
readonly provider: ReturnType<IProviderService['get']>;
readonly profileName: string | undefined;
readonly thinkingLevel: string;
readonly systemPrompt: string;
@ -477,6 +477,31 @@ export function homeDirServices(homeDir: string | undefined): TestAgentServiceOv
});
}
/**
* Override the App-scope `IHostEnvironment` with a fully-populated POSIX
* snapshot whose `homeDir` points at a hermetic test directory. Used by tests
* that render system prompts / resolve user-level config so a developer's real
* `~/.kimi-code` / `~/.agents` files never leak into the assertions.
*/
export function hostEnvironmentServices(homeDir: string): TestAgentServiceOverride {
return appServices((reg) => {
reg.defineInstance(
IHostEnvironment,
{
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir,
ready: Promise.resolve(),
} satisfies IHostEnvironment,
);
});
}
export function additionalDirServices(additionalDirs: readonly string[]): TestAgentServiceOverride {
return sessionServices((reg) => {
reg.defineInstance(
@ -1963,10 +1988,13 @@ function isSystemReminderMessage(message: ContextMessage): boolean {
function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config'] {
const profile = ctx.get(IAgentProfileService);
const data = profile.data();
const model = profile.resolveModel();
const providerConfig =
model === undefined ? undefined : ctx.get(IProviderService).get(model.providerName);
return {
cwd: data.cwd,
activeToolNames: data.activeToolNames,
provider: data.provider,
provider: providerConfig,
profileName: data.profileName,
thinkingLevel: data.thinkingLevel,
systemPrompt: data.systemPrompt,
@ -2220,7 +2248,7 @@ function createGenerateBackedProtocolRegistry(generate: GenerateFn): IProtocolAd
baseUrl: input.baseUrl,
apiKey: input.apiKey,
defaultHeaders: input.defaultHeaders as Record<string, string> | undefined,
...(input.providerOptions ?? {}),
...input.providerOptions,
} as ProviderConfig;
return input.protocol === 'kimi'
? new GenerateBackedKimiChatProvider(

View file

@ -12,6 +12,7 @@ export {
execEnvServices,
externalHookServices,
homeDirServices,
hostEnvironmentServices,
InMemoryWireRecordPersistence,
llmGenerateServices,
logServices,

View file

@ -1,20 +1,23 @@
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector';
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService';
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentUsageService } from '#/agent/usage';
import { IConfigService } from '#/app/config';
import { APIStatusError, emptyUsage, type Message, type ModelCapability } from '#/app/llmProtocol';
import type { Model } from '#/app/model';
import { ITelemetryService } from '#/app/telemetry';
import { ILogService } from '#/_base/log';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IConfigService } from '#/app/config/config';
import { APIStatusError } from '#/app/llmProtocol/errors';
import { emptyUsage } from '#/app/llmProtocol/usage';
import type { Message } from '#/app/llmProtocol/message';
import type { ModelCapability } from '#/app/llmProtocol/capability';
import type { LLMEvent, Model } from '#/app/model/modelInstance';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ILogService } from '#/_base/log/log';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const capabilities: ModelCapability = {
@ -156,6 +159,8 @@ describe('AgentLLMRequesterService strict resend', () => {
const model = createModel({ value: 0 });
Object.defineProperty(model, 'request', {
value: async function* () {
const events: LLMEvent[] = [];
for (const event of events) yield event;
throw new APIStatusError(401, 'unauthorized');
},
});

View file

@ -1,4 +1,3 @@
import type { AgentEvent } from '@moonshot-ai/protocol';
import type { ContentPart } from '#/app/llmProtocol/message';
import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -6,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import type { McpConnectionManager, McpServerEntry } from '#/agent/mcp/connection-manager';
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { AgentMcpService } from '#/agent/mcp/mcpService';
@ -128,17 +127,17 @@ class FakeMcpManager {
describe('AgentMcpService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let events: AgentEvent[];
let events: DomainEvent[];
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
events = [];
ix.stub(IAgentEventSinkService, {
emit: (event) => {
ix.stub(IEventBus, {
publish: (event) => {
events.push(event);
},
on: () => toDisposable(() => {}),
subscribe: () => toDisposable(() => {}),
});
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));

View file

@ -9,12 +9,17 @@ import { describe, expect, test } from 'vitest';
import { convertMCPContentBlock, mcpResultToExecutableOutput } from '#/agent/mcp/output';
import { createMcpTool } from '#/agent/mcp/tools/mcp';
import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/agent/mcp/types';
import type { ToolExecution } from '#/agent/tool/toolContract';
import { sniffImageDimensions } from '#/_base/tools/support/file-type';
const MCP_OUTPUT_TRUNCATED_TEXT =
'\n\n[Output truncated: exceeded 100000 character limit. ' +
'Use pagination or more specific queries to get remaining content.]';
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
function assertValidMcpBlock<T extends MCPContentBlock>(block: T): T {
const parsed = ContentBlockSchema.safeParse(block);
if (!parsed.success) {
@ -477,7 +482,8 @@ describe('createMcpTool', () => {
{ name: 'tool', description: 'Tool', parameters: {} },
client,
);
const execution = tool.resolveExecution({});
const resolved = tool.resolveExecution({});
const execution = isPromiseLike(resolved) ? await resolved : resolved;
if (execution.isError === true) throw new Error('expected executable tool call');
const result = await execution.execute({

View file

@ -35,6 +35,8 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentTurnService } from '#/agent/turn/turn';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type { ToolCall } from '#/app/llmProtocol/message';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
import { stubApprovalService } from '../approval/stubs';
@ -126,6 +128,7 @@ describe('AgentPermissionGate', () => {
});
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
reg.define(IEventBus, EventBusService);
reg.defineInstance(IAgentScopeContext, {
_serviceBrand: undefined,
agentId: 'main',
@ -397,13 +400,14 @@ describe('AgentPermissionGate', () => {
approvalResponse = { decision: 'approved', selectedLabel: 'Approve once' };
const request = setApprovalRequest(async () => approvalResponse);
const svc = make();
const eventBus = ix.get(IEventBus);
disposables.add(
svc.hooks.onDidRequestApproval.register('test', (ctx) => {
eventBus.subscribe('permission.approval.requested', (ctx) => {
permissionRequest(ctx);
}),
);
disposables.add(
svc.hooks.onDidResolveApproval.register('test', (ctx) => {
eventBus.subscribe('permission.approval.resolved', (ctx) => {
permissionResult(ctx);
}),
);
@ -413,6 +417,7 @@ describe('AgentPermissionGate', () => {
expect(request).toHaveBeenCalledTimes(1);
expect(permissionRequest).toHaveBeenCalledWith({
type: 'permission.approval.requested',
sessionId: 'test-session',
agentId: 'main',
turnId: 1,
@ -427,6 +432,7 @@ describe('AgentPermissionGate', () => {
},
});
expect(permissionResult).toHaveBeenCalledWith({
type: 'permission.approval.resolved',
sessionId: 'test-session',
agentId: 'main',
turnId: 1,

View file

@ -135,7 +135,7 @@ describe('AgentPermissionModeService (wire-backed)', () => {
const log2 = ix2.get(IAppendLogStore);
const fresh = ix2.get(IAgentWireService);
fresh.replay({ type: 'permission.set_mode', mode: 'auto' });
void fresh.replay({ type: 'permission.set_mode', mode: 'auto' });
expect(fresh.getModel(PermissionModeModel)).toBe('auto');

View file

@ -149,7 +149,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => {
let changes = 0;
disposables.add(fresh.subscribe(PermissionRulesModel, () => (changes += 1)));
fresh.replay(...records);
void fresh.replay(...records);
expect(fresh.getModel(PermissionRulesModel)).toEqual({
rules: [allowRule, denyRule],

View file

@ -5,6 +5,7 @@ import { Emitter } from '#/_base/event';
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
import { AgentPluginService } from '#/agent/plugin/agentPluginService';
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import { IEventBus } from '#/app/event/eventBus';
import { IPluginService } from '#/app/plugin/plugin';
import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types';
@ -129,6 +130,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
ctx.get(IEventBus).publish({
type: 'turn.started',
turnId: 2,
origin: USER_PROMPT_ORIGIN,
});
await injectRegistered(ctx);

View file

@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile';
import { createTestAgent, execEnvServices, type TestAgentContext } from '../harness';
import { createTestAgent, execEnvServices, hostEnvironmentServices, type TestAgentContext } from '../harness';
const profile: ResolvedAgentProfile = {
name: 'agents-profile',
@ -38,10 +38,8 @@ describe('AgentProfileService.applyProfile', () => {
// never leak into the assertions.
const fs = new HostFileSystem();
ctx = createTestAgent(
execEnvServices({
hostEnvironment: { homeDir },
hostFs: fs,
}),
execEnvServices({ hostFs: fs }),
hostEnvironmentServices(homeDir),
{ cwd: workDir },
);
return { ctx, profile: ctx.get(IAgentProfileService) };

View file

@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IAgentProfileService } from '#/agent/profile/profile';
import { createTestAgent, execEnvServices, type TestAgentContext } from '../harness';
import { createTestAgent, hostEnvironmentServices, type TestAgentContext } from '../harness';
const MOCK_MODEL = 'mock-model';
@ -27,7 +27,7 @@ describe('AgentProfileService.bind', () => {
function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } {
// Hermetic home dir so a developer's real ~/.kimi-code / ~/.agents files
// never leak into the rendered system prompt.
ctx = createTestAgent(execEnvServices({ hostEnvironment: { homeDir } }));
ctx = createTestAgent(hostEnvironmentServices(homeDir));
return { ctx, profile: ctx.get(IAgentProfileService) };
}

View file

@ -67,7 +67,7 @@ describe('ConfigState model capabilities', () => {
profile.update({ modelAlias: 'kimi-code/kimi-for-coding' });
expect(profile.getModel()).toBe('kimi-code/kimi-for-coding');
expect(profile.data().provider?.model).toBe('kimi-for-coding');
expect(profile.resolveModel()?.name).toBe('kimi-for-coding');
expect(profile.getModelCapabilities()).toMatchObject({
image_in: true,
video_in: true,
@ -187,12 +187,11 @@ describe('ConfigState prompt cache hint', () => {
it('uses session id as a provider prompt cache hint without storing it on Agent', () => {
profile.update({ modelAlias: 'kimi-code' });
expect(profile.data().provider).toMatchObject({
type: 'kimi',
generationKwargs: {
prompt_cache_key: 'session-test',
},
});
// The session id is now applied to the resolved `Model`'s generation kwargs
// (`prompt_cache_key`) by `AgentProfileService.resolveModel` for kimi
// models; the `Model` god-object no longer exposes the raw provider config,
// so we assert the resolved protocol and the "not stored on Agent" invariant.
expect(profile.resolveModel()?.protocol).toBe('kimi');
expect('sessionId' in ctx).toBe(false);
});
});

View file

@ -11,7 +11,7 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import type { GenerationKwargs } from '#/app/llmProtocol/kimiOptions';
import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort';
import { type Model } from '#/app/model/modelInstance';
import { type LLMEvent, type Model } from '#/app/model/modelInstance';
import { IModelResolver } from '#/app/model/modelResolver';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
@ -179,7 +179,8 @@ function createRecordingModel(
return build(thinkingEffort);
},
request: async function* () {
return;
const events: LLMEvent[] = [];
for (const event of events) yield event;
},
});
return build(null);
@ -249,7 +250,7 @@ describe('AgentProfileService (wire-backed config.update)', () => {
},
});
host.wire.replay(...records);
void host.wire.replay(...records);
expect(modelOf(host.wire).cwd).toBe('/work');
expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME);
expect(replayChdir).toBe(0);
@ -269,7 +270,7 @@ describe('AgentProfileService (wire-backed config.update)', () => {
// Fresh host whose config section would resolve differently is irrelevant:
// the persisted resolved value ('on') is restored verbatim.
const host = buildHost('profile-replay-thinking');
host.wire.replay(...records);
void host.wire.replay(...records);
expect(modelOf(host.wire).thinkingLevel).toBe('on');
});

View file

@ -15,7 +15,7 @@ import {
} from 'node:fs';
import { dirname, join } from 'node:path';
import { describe, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { TestInstantiationService } from '#/_base/di/test';
@ -103,14 +103,16 @@ describe('resume round-trip diff diagnosis', () => {
await wire2.restore();
const r2 = wire2.getRecords();
expect(r2.length).toBe(r1.length);
const a = r1[index] as Record<string, unknown> | undefined;
const b = r2[index] as Record<string, unknown> | undefined;
if (a === undefined || b === undefined) {
console.log(`r1[${index}] type=${a?.type}, r2[${index}] type=${b?.type} (one side undefined)`);
console.log(`r1[${index}] type=${String(a?.['type'])}, r2[${index}] type=${String(b?.['type'])} (one side undefined)`);
continue;
}
const diff = fieldDiff(a, b);
console.log(`r1[${index}].type=${a.type}, r2[${index}].type=${b.type}`);
console.log(`r1[${index}].type=${String(a['type'])}, r2[${index}].type=${String(b['type'])}`);
console.log(`changed fields: ${Object.keys(diff).join(', ') || '(none)'}`);
for (const [key, value] of Object.entries(diff)) {
console.log(`--- field "${key}" ---`);

View file

@ -23,7 +23,7 @@ import {
} from 'node:fs';
import { dirname, join } from 'node:path';
import { describe, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { TestInstantiationService } from '#/_base/di/test';
@ -183,7 +183,7 @@ async function mapPool<T, R>(
fn: (item: T, index: number) => Promise<R>,
onProgress?: (done: number) => void,
): Promise<R[]> {
const results: R[] = new Array(items.length);
const results = Array.from<R>({ length: items.length });
let next = 0;
let done = 0;
async function worker(): Promise<void> {
@ -262,5 +262,8 @@ describe('resume round-trip over real ~/.kimi-code wire logs', () => {
);
}
}
expect(restoreErrors).toEqual([]);
expect(mismatches).toEqual([]);
}, 60 * 60 * 1000);
});

View file

@ -665,7 +665,7 @@ function context(
signal = new AbortController().signal,
onForegroundTaskStart?: (taskId: string) => void,
) {
return { turnId: '0', toolCallId: 'call_bash', args, signal, onForegroundTaskStart };
return { turnId: 0, toolCallId: 'call_bash', args, signal, onForegroundTaskStart };
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {

View file

@ -6,7 +6,7 @@ import type { ToolCall } from '#/app/llmProtocol/message';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentProfileService } from '#/agent/profile/profile';
import { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
import { type SkillCatalog, type SkillDefinition } from '#/app/skillCatalog/types';
@ -295,8 +295,8 @@ describe('ToolManager SkillTool restore behavior', () => {
telemetryServices(telemetry),
);
context = ctx.get(IAgentContextMemoryService);
const events = ctx.get(IAgentEventSinkService);
emit = vi.spyOn(events, 'emit');
const events = ctx.get(IEventBus);
emit = vi.spyOn(events, 'publish');
});
afterEach(async () => {

View file

@ -88,7 +88,7 @@ describe('JsonAtomicDocumentStore', () => {
});
});
await config.set<State>('session', 'state.json', { title: 'x' });
await fired;
await expect(fired).resolves.toBeUndefined();
});
});
@ -139,6 +139,6 @@ describe('TomlAtomicDocumentStore', () => {
});
});
await config.set<State>('session', 'config.toml', { title: 'x' });
await fired;
await expect(fired).resolves.toBeUndefined();
});
});

View file

@ -101,7 +101,7 @@ function storageServiceSuite(
});
await settle();
await service.write('s', 'k', enc.encode('v'));
await fired;
await expect(fired).resolves.toBeUndefined();
});
it('watch does not fire for an unrelated key', async ({ skip }) => {
@ -127,7 +127,7 @@ function storageServiceSuite(
});
await settle();
await service.delete('s', 'k');
await fired;
await expect(fired).resolves.toBeUndefined();
});
});
}

View file

@ -123,7 +123,7 @@ describe('AgentSwarmService', () => {
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'swarm-replay' }]),
);
const fresh = ix2.get(IAgentWireService);
fresh.replay(...records);
void fresh.replay(...records);
expect(fresh.getModel(SwarmModel)).toBe('manual');
});
});

View file

@ -12,7 +12,7 @@ import {
IAgentTaskService,
type AgentTaskInfo,
} from '#/agent/task/task';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IEventBus } from '#/app/event/eventBus';
import {
taskServices,
createTestAgent,
@ -63,8 +63,8 @@ describe('Background reconcile — stale ghost detection', () => {
ctx = createTestAgent(homeDirServices(sessionDir), taskServices());
background = ctx.get(IAgentTaskService) as TaskServiceTestManager;
emittedEvents = [];
const events = ctx.get(IAgentEventSinkService);
events.on((event) => {
const events = ctx.get(IEventBus);
events.subscribe((event) => {
emittedEvents.push(event);
});
});

View file

@ -12,7 +12,7 @@ import {
IAgentTaskService,
type AgentTaskInfo,
} from '#/agent/task/task';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IEventBus } from '#/app/event/eventBus';
import {
taskServices,
createTestAgent,
@ -91,8 +91,8 @@ describe('AgentTaskService — loadFromDisk + reconcile', () => {
ctx = createTestAgent(homeDirServices(sessionDir), taskServices());
background = ctx.get(IAgentTaskService) as TaskServiceTestManager;
emittedEvents = [];
const events = ctx.get(IAgentEventSinkService);
events.on((event) => {
const events = ctx.get(IEventBus);
events.subscribe((event) => {
emittedEvents.push(event);
});
});

View file

@ -21,7 +21,7 @@ import {
} from '#/session/agentLifecycle/tools/subagent-task';
import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IEventBus } from '#/app/event/eventBus';
import type { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
@ -200,8 +200,8 @@ function createAgentTaskService(options: {
const ctx = createTestAgent(...overrides);
const emittedEvents: Array<{ type: string; info?: unknown }> = [];
const events = ctx.get(IAgentEventSinkService);
const disposable = events.on((event) => {
const events = ctx.get(IEventBus);
const disposable = events.subscribe((event) => {
emittedEvents.push(event as { type: string; info?: unknown });
});

View file

@ -108,7 +108,7 @@ class EchoTool implements ExecutableTool<Record<string, unknown>> {
constructor(
readonly name = 'Echo',
private readonly resultFor: (args: Record<string, unknown>) => ExecutableToolResult = (args) => ({
output: String(args['text'] ?? ''),
output: typeof args['text'] === 'string' ? args['text'] : '',
}),
) {}

View file

@ -338,7 +338,8 @@ describe('AgentToolExecutorService', () => {
],
{ turnId: 0, signal: new AbortController().signal },
)) {
yielded.push(String(item.result.output));
const output = item.result.output;
yielded.push(typeof output === 'string' ? output : JSON.stringify(output));
if (yielded.length === 1) firstYielded.resolve();
}
})();
@ -720,7 +721,9 @@ class TestTool implements ExecutableTool<Record<string, unknown>> {
if (this.options.execute !== undefined) {
return this.options.execute(ctx, args);
}
return this.options.result ?? { output: String(args['text'] ?? `${this.name} result`) };
return this.options.result ?? {
output: typeof args['text'] === 'string' ? args['text'] : `${this.name} result`,
};
},
};
}

View file

@ -368,7 +368,7 @@ describe('AgentTurnService wire state', () => {
const log2 = ix2.get(IAppendLogStore);
const fresh = ix2.get(IAgentWireService);
fresh.replay(...records);
void fresh.replay(...records);
// nextTurnId advances past the replayed turnId (0 -> 1).
expect(fresh.getModel(TurnModel)).toEqual({ nextTurnId: 1 });

View file

@ -7,8 +7,8 @@ import { setTimeout as delay } from 'node:timers/promises';
import { type ModelCapability } from '#/app/llmProtocol/capability';
import { APIConnectionError, APIEmptyResponseError, APIStatusError, APITimeoutError } from '#/app/llmProtocol/errors';
import { type ToolCall } from '#/app/llmProtocol/message';
import { type ProviderRequestAuth } from '#/app/llmProtocol/request';
import type { ChatProvider } from '#/app/llmProtocol/provider';
import { IProtocolAdapterRegistry } from '#/app/protocol/protocol';
import { describe, expect, it, vi } from 'vitest';
import { abortError, abortable } from '#/_base/utils/abort';
@ -22,8 +22,7 @@ import { makeHookRunner } from '../externalHooks/runner-stub';
import type { ILogger as Logger, LogPayload } from '#/_base/log/log';
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { McpConnectionManager } from '#/agent/mcp/connection-manager';
import { registerMediaTools } from '#/agent/media/registerMediaTools';
import { type VideoUploader } from '#/agent/media/tools/read-media';
import { createVideoUploader, registerMediaTools } from '#/agent/media/registerMediaTools';
import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
@ -1753,10 +1752,27 @@ describe('Agent turn flow', () => {
throw new APIStatusError(401, 'Unauthorized', 'req-upload-401');
}),
} as unknown as ChatProvider;
const ctx = testAgent(oauthOptions.services, execEnvServices({ hostFs: createVideoHostFs() }), {
initialConfig: oauthOptions.initialConfig,
autoConfigure: false,
});
// The OAuth force-refresh-on-401 behavior now lives inside the `Model`
// god-object (`uploadVideo` runs the provider call through the auth-refresh
// wrapper). Inject the fake provider via the protocol registry so the
// resolved Model uses it, then bind `model.uploadVideo` into the media
// tool's `VideoUploader` shape the way the production wiring does.
const fakeRegistry = {
_serviceBrand: undefined,
supportedProtocols: () => ['vertexai'] as const,
createChatProvider: () => provider,
} satisfies IProtocolAdapterRegistry & { createChatProvider: () => ChatProvider };
const ctx = testAgent(
oauthOptions.services,
appServices((reg) => {
reg.defineInstance(IProtocolAdapterRegistry, fakeRegistry);
}),
execEnvServices({ hostFs: createVideoHostFs() }),
{
initialConfig: oauthOptions.initialConfig,
autoConfigure: false,
},
);
const profile = ctx.get(IAgentProfileService);
profile.update({
cwd: '/workspace',
@ -1764,14 +1780,8 @@ describe('Agent turn flow', () => {
systemPrompt: 'test system prompt',
thinkingLevel: 'off',
});
const withAuth = ctx.modelResolver.resolveAuth?.('kimi-code');
if (withAuth === undefined) throw new Error('OAuth model did not resolve auth wrapper');
const videoUploader: VideoUploader = (input) =>
withAuth((auth: ProviderRequestAuth) => {
const uploadVideo = provider.uploadVideo;
if (uploadVideo === undefined) throw new Error('Provider did not expose uploadVideo');
return uploadVideo.call(provider, input, { auth });
});
const videoUploader = createVideoUploader(ctx.modelResolver.resolve('kimi-code'));
if (videoUploader === undefined) throw new Error('OAuth model did not resolve a video uploader');
const registration = registerMediaTools(ctx.get(IAgentToolRegistryService), {
fs: ctx.get(IHostFileSystem),
env: ctx.get(IHostEnvironment),

View file

@ -160,7 +160,7 @@ describe('AgentUsageService (wire-backed)', () => {
const log2 = ix2.get(IAppendLogStore);
const fresh = ix2.get(IAgentWireService);
fresh.replay(...records);
void fresh.replay(...records);
expect(fresh.getModel(UsageModel).byModel).toEqual({
'model-a': { inputOther: 11, output: 22, inputCacheRead: 33, inputCacheCreation: 44 },

View file

@ -98,6 +98,7 @@ class MemoryHostFs implements IHostFileSystem {
}
async *readLines(): AsyncGenerator<string> {
yield* [];
throw new Error('not implemented');
}

View file

@ -25,7 +25,7 @@
* - logout delegates to facade.logout
*/
import { describe, expect, it, vi } from 'vitest';
import { assert, describe, expect, it, vi } from 'vitest';
import {
DeviceCodeTimeoutError,
@ -129,7 +129,7 @@ describe('OAuthService.startLogin', () => {
await mock.loginCalls[0]!.onDeviceCode?.(auth);
const start = await startPromise;
expect(start.status).toBe('pending');
assert(start.status === 'pending');
expect(start.flow_id).toMatch(/^oauth_/);
expect(start.verification_uri).toBe(auth.verificationUri);
expect(start.verification_uri_complete).toBe(auth.verificationUriComplete);
@ -147,6 +147,7 @@ describe('OAuthService.startLogin', () => {
fakeDeviceAuth({ expiresIn: null }),
);
const start = await startPromise;
assert(start.status === 'pending');
expect(start.expires_in).toBe(15 * 60);
});
});

View file

@ -313,15 +313,15 @@ function sendMappedError(
}
function authProviderDetails(err: KimiError): { provider_id: string } | undefined {
const providerId = err.details?.provider_id;
const providerId = err.details?.['provider_id'];
if (typeof providerId !== 'string') return undefined;
return { provider_id: providerId };
}
function authModelDetails(err: KimiError): { model_id?: string; provider_id?: string } | null {
const details: { model_id?: string; provider_id?: string } = {};
const modelId = err.details?.model_id;
const providerId = err.details?.provider_id;
const modelId = err.details?.['model_id'];
const providerId = err.details?.['provider_id'];
if (typeof modelId === 'string') details.model_id = modelId;
if (typeof providerId === 'string') details.provider_id = providerId;
return Object.keys(details).length === 0 ? null : details;

View file

@ -61,7 +61,6 @@ import {
IAuthSummaryService,
ISessionBtwService,
ISessionActivity,
ISessionContext,
ISessionIndex,
ISessionLifecycleService,
ISessionMetadata,

View file

@ -41,7 +41,6 @@ import {
} from '@moonshot-ai/agent-core-v2';
import type {
InFlightTurn,
Message,
SessionSnapshotResponse,
SessionStatus,
} from '@moonshot-ai/protocol';

View file

@ -5,7 +5,7 @@
*
* Only the high-value streams are exposed for now:
* Core `events` process-wide `GlobalEvent` bus (`IEventService`)
* Session `interactions` pending human-in-the-loop requests (`ISessionInteractionService.onDidChange`)
* Session `interactions` pending human-in-the-loop requests (`ISessionInteractionService.onDidChangePending`)
* Session `interactions:resolved` request resolutions (`ISessionInteractionService.onDidResolve`)
* Agent `events` per-agent `AgentEvent` stream (live events via
* the per-agent `IEventBus`)
@ -44,7 +44,7 @@ export const eventMap: Record<ScopeKind, Record<string, EventSource>> = {
interactions: {
subscribe: (scope, listener) => {
const interaction = scope.accessor.get(ISessionInteractionService);
return interaction.onDidChange(() => listener(interaction.listPending()));
return interaction.onDidChangePending(() => listener(interaction.listPending()));
},
},
// Pushes `{ id, response }` whenever a pending request is responded to.

View file

@ -64,7 +64,7 @@ export class WsClient {
this.ws.on('open', () => {
this.ws.send(JSON.stringify({ type: 'hello', token: opts.token }));
});
this.ws.on('message', (data) => this.onMessage(data.toString()));
this.ws.on('message', (data) => this.onMessage((data as Buffer).toString()));
this.ws.on('error', (err) => {
this.rejectReady(err);
for (const p of this.pending.values()) p.reject(err);

View file

@ -143,7 +143,7 @@ export class WsConnection {
this.cancel(msg.id);
return;
case 'listen':
this.onListen(msg);
void this.onListen(msg);
return;
case 'unlisten':
this.cancel(msg.id);

View file

@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { modelResolverSeed, SingleModelResolver } from '@moonshot-ai/agent-core-v2';
import { IModelResolver } from '@moonshot-ai/agent-core-v2';
import { ErrorCode } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -38,17 +38,19 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => {
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-home-'));
work = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-work-'));
const modelResolver = new SingleModelResolver({
type: 'openai',
model: 'stub',
apiKey: 'stub',
});
const modelResolver: IModelResolver = {
_serviceBrand: undefined,
resolve: () => {
throw new Error('modelResolver.resolve not exercised in this test');
},
findByName: () => [],
};
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: modelResolverSeed(modelResolver),
seeds: [[IModelResolver, modelResolver]],
});
base = `http://127.0.0.1:${server.port}`;
});

View file

@ -7,8 +7,7 @@ import {
IAgentLifecycleService,
IAgentWireRecordService,
ISessionLifecycleService,
modelResolverSeed,
SingleModelResolver,
IModelResolver,
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -50,12 +49,14 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-messages-'));
// Seed a stub ISessionModelResolver so the agent scope can instantiate if a
// transitive service needs it; IContextMemory itself does not.
const modelResolver = new SingleModelResolver({
type: 'openai',
model: 'stub',
apiKey: 'stub',
});
seeds = modelResolverSeed(modelResolver);
const modelResolver: IModelResolver = {
_serviceBrand: undefined,
resolve: () => {
throw new Error('modelResolver.resolve not exercised in this test');
},
findByName: () => [],
};
seeds = [[IModelResolver, modelResolver]];
await boot();
});

View file

@ -8,8 +8,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
type DomainEvent,
IAgentContextMemoryService,
IAgentEventSinkService,
IEventBus,
IAgentLifecycleService,
IAgentPromptLegacyService,
ILogService,
@ -20,7 +21,7 @@ import {
ISessionMetadata,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
import { sessionSnapshotResponseSchema, type AgentEvent } from '@moonshot-ai/protocol';
import { sessionSnapshotResponseSchema } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerSnapshotRoutes } from '../src/routes/snapshot';
@ -260,10 +261,10 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
if (agents.getHandle('main') === undefined) await agents.create({ agentId: 'main' });
}
function emit(sessionId: string, event: AgentEvent): void {
function emit(sessionId: string, event: DomainEvent): void {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
const main = session!.accessor.get(IAgentLifecycleService).getHandle('main');
main!.accessor.get(IAgentEventSinkService).emit(event);
main!.accessor.get(IEventBus).publish(event);
}
async function snapshot(sid: string) {
@ -296,8 +297,8 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
emit(sid, {
type: 'turn.started',
turnId: 1,
} as unknown as AgentEvent); // durable → seq 1
emit(sid, { type: 'assistant.delta', turnId: 1, delta: 'Hello' } as unknown as AgentEvent); // volatile
} as unknown as DomainEvent); // durable → seq 1
emit(sid, { type: 'assistant.delta', turnId: 1, delta: 'Hello' } as unknown as DomainEvent); // volatile
const snap = await snapshot(sid);
expect(snap.as_of_seq).toBe(1);

View file

@ -6,8 +6,7 @@ import {
IAgentLifecycleService,
IAgentTaskService,
ISessionLifecycleService,
modelResolverSeed,
SingleModelResolver,
IModelResolver,
type AgentTask,
} from '@moonshot-ai/agent-core-v2';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -50,17 +49,19 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-tasks-'));
// Seed a stub ISessionModelResolver so the agent scope can instantiate if a
// transitive service needs it; IAgentTaskService itself does not.
const modelResolver = new SingleModelResolver({
type: 'openai',
model: 'stub',
apiKey: 'stub',
});
const modelResolver: IModelResolver = {
_serviceBrand: undefined,
resolve: () => {
throw new Error('modelResolver.resolve not exercised in this test');
},
findByName: () => [],
};
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: modelResolverSeed(modelResolver),
seeds: [[IModelResolver, modelResolver]],
});
base = `http://127.0.0.1:${server.port}`;
});

View file

@ -21,8 +21,7 @@ import {
IAgentLifecycleService,
IAgentToolRegistryService,
ISessionLifecycleService,
modelResolverSeed,
SingleModelResolver,
IModelResolver,
type ExecutableTool,
} from '@moonshot-ai/agent-core-v2';
import {
@ -56,17 +55,19 @@ describe('server-v2 /api/v1 tools + mcp', () => {
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-tools-'));
const modelResolver = new SingleModelResolver({
type: 'openai',
model: 'stub',
apiKey: 'stub',
});
const modelResolver: IModelResolver = {
_serviceBrand: undefined,
resolve: () => {
throw new Error('modelResolver.resolve not exercised in this test');
},
findByName: () => [],
};
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: modelResolverSeed(modelResolver),
seeds: [[IModelResolver, modelResolver]],
});
base = `http://127.0.0.1:${server.port}`;
});

View file

@ -9,11 +9,11 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
IAgentEventSinkService,
type DomainEvent,
IEventBus,
IAgentLifecycleService,
ISessionLifecycleService,
} from '@moonshot-ai/agent-core-v2';
import type { AgentEvent } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
@ -48,7 +48,7 @@ function openConn(url: string, token: string): Promise<Conn> {
ws.on('message', (data) => {
let frame: Frame;
try {
frame = JSON.parse(data.toString()) as Frame;
frame = JSON.parse((data as Buffer).toString()) as Frame;
} catch {
return;
}
@ -139,13 +139,13 @@ describe('server-v2 /api/v1/ws resync', () => {
return { ...payload, token: server!.authTokenService.getToken() };
}
function emitAgentEvent(sessionId: string, event: AgentEvent): void {
function emitAgentEvent(sessionId: string, event: DomainEvent): void {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
expect(session).toBeDefined();
const agents = session!.accessor.get(IAgentLifecycleService);
const main = agents.getHandle('main');
expect(main).toBeDefined();
main!.accessor.get(IAgentEventSinkService).emit(event);
main!.accessor.get(IEventBus).publish(event);
}
it('server_hello then client_hello ack with accepted subscription', async () => {
@ -175,7 +175,7 @@ describe('server-v2 /api/v1/ws resync', () => {
c.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) });
await c.next((f) => f.type === 'ack' && f.id === 'h1');
emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as AgentEvent);
emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as DomainEvent);
const ev = await c.next((f) => f.type === 'turn.started');
expect(ev.seq).toBe(1);
@ -195,8 +195,8 @@ describe('server-v2 /api/v1/ws resync', () => {
await c1.next((f) => f.type === 'server_hello');
c1.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) });
await c1.next((f) => f.type === 'ack' && f.id === 'h1');
emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as AgentEvent);
emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as AgentEvent);
emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as DomainEvent);
emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as DomainEvent);
await c1.next((f) => f.type === 'turn.ended');
c1.ws.close();
await c1.closed;

View file

@ -25,7 +25,7 @@ async function tmpDir() {
return fs.mkdtemp(path.join(os.tmpdir(), 'baseline-bench-'));
}
async function bench(label: string, fn: () => Promise<unknown> | unknown) {
async function bench(label: string, fn: () => unknown) {
if (global.gc) global.gc();
const t0 = performance.now();
await fn();

View file

@ -28,7 +28,6 @@ const gc = (): void => {
}
};
const mib = (n: number): string => (n / 1048576).toFixed(1) + ' MiB';
const kb = (n: number): string => (n / 1024).toFixed(1) + ' KiB';
const fmt = (n: number): string => n.toLocaleString('en-US', { maximumFractionDigits: 0 });
interface Snap {

View file

@ -86,11 +86,10 @@ function loadAllMessages(): Msg[] {
function scaleTo(real: Msg[], n: number): Msg[] {
const now = Date.now();
const span = WINDOW_DAYS * 864e5;
const out: Msg[] = new Array(n);
for (let i = 0; i < n; i++) {
const out: Msg[] = Array.from({ length: n }, (_, i) => {
const r = real[i % real.length]!;
out[i] = { id: `m${i}`, ts: now - Math.floor((i / n) * span), role: r.role, text: r.text };
}
return { id: `m${i}`, ts: now - Math.floor((i / n) * span), role: r.role, text: r.text };
});
return out;
}

View file

@ -79,16 +79,15 @@ function loadRealMessages(): Msg[] {
function scaleTo(real: Msg[], n: number): Msg[] {
const now = Date.now();
const span = WINDOW_DAYS * 864e5;
const out: Msg[] = new Array(n);
for (let i = 0; i < n; i++) {
const out: Msg[] = Array.from({ length: n }, (_, i) => {
const r = real[i % real.length]!;
out[i] = {
return {
id: `m${i}`,
ts: now - Math.floor((i / n) * span),
role: r.role,
text: r.text,
};
}
});
return out;
}

View file

@ -78,7 +78,7 @@ function extractWireText(wirePath: string, full: boolean): string {
function tokenize(s: string): string[] {
return s
.toLowerCase()
.split(/[\s,,。.!??、;:()\[\]{}"'<>/\\|@#%^&*+=~`\-_]+/)
.split(/[\s,,。.!??、;:()[\]{}"'<>/\\|@#%^&*+=~`\-_]+/)
.filter((t) => t.length > 0);
}

View file

@ -134,11 +134,11 @@ async function main() {
value: {
title: `session ${n.id}`,
workspaceId: 'ws_bench',
metadata: {
// Only real children carry parent_session_id (+ kind). Roots do
// Only real children carry parent_session_id (+ kind). Roots do
// not, so they stay out of the byParent index entirely.
...(n.parentId ? { parent_session_id: n.parentId, child_session_kind: 'child' } : {}),
},
metadata: n.parentId
? { parent_session_id: n.parentId, child_session_kind: 'child' }
: {},
},
dt: { updatedAt: n.updatedAt },
})),

View file

@ -36,7 +36,7 @@ interface CompoundEntry {
}
function getPath(doc: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((o, k) => (o == null ? undefined : (o as Record<string, unknown>)[k]), doc);
return path.split('.').reduce<unknown>((o, k) => (o === null || o === undefined ? undefined : (o as Record<string, unknown>)[k]), doc);
}
export class CompoundIndexManager {
@ -113,7 +113,7 @@ export class CompoundIndexManager {
}
}
remove(pk: string, doc?: unknown, dt?: Record<string, number> | null): void {
remove(pk: string, _doc?: unknown, _dt?: Record<string, number> | null): void {
for (const entry of this.indexes.values()) {
const prev = entry.byPk.get(pk);
if (prev) {

View file

@ -54,7 +54,7 @@ export class UniqueViolationError extends Error {
}
function getField(doc: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((o, k) => (o == null ? undefined : (o as Record<string, unknown>)[k]), doc);
return path.split('.').reduce<unknown>((o, k) => (o === null || o === undefined ? undefined : (o as Record<string, unknown>)[k]), doc);
}
function stableStringify(v: unknown): string {
@ -66,7 +66,7 @@ function stableStringify(v: unknown): string {
function scalarKey(v: unknown): string {
const t = typeof v;
if (t === 'string' || t === 'number' || t === 'boolean') return `${t}:${v}`;
if (t === 'string' || t === 'number' || t === 'boolean') return `${t}:${String(v)}`;
// Canonicalize property order so {a:1,b:2} and {b:2,a:1} hash to the same
// key; JSON.stringify alone preserves insertion order and would split them.
return `json:${stableStringify(v)}`;
@ -290,8 +290,14 @@ export class IndexManager {
const idx = this.get(name);
if (idx.type !== 'range') throw new Error(`index "${name}" is not a range index`);
const r: RangeOptions<number> = {};
if (opts.min !== undefined) (opts.minExclusive ? (r.gt = opts.min) : (r.gte = opts.min));
if (opts.max !== undefined) (opts.maxExclusive ? (r.lt = opts.max) : (r.lte = opts.max));
if (opts.min !== undefined) {
if (opts.minExclusive) r.gt = opts.min;
else r.gte = opts.min;
}
if (opts.max !== undefined) {
if (opts.maxExclusive) r.lt = opts.max;
else r.lte = opts.max;
}
if (opts.offset) r.offset = opts.offset;
if (opts.count !== undefined) r.count = opts.count;
if (opts.reverse) r.reverse = true;

View file

@ -32,7 +32,7 @@ function tokenizePath(path: Path): (string | number)[] {
export function getPath(doc: Doc, path: Path): unknown {
let cur: unknown = doc;
for (const t of tokenizePath(path)) {
if (cur == null) return undefined;
if (cur === null || cur === undefined) return undefined;
cur = (cur as Record<string | number, unknown>)[t];
}
return cur;
@ -43,7 +43,7 @@ export function setPath(obj: Doc, path: Path, value: unknown): Doc {
let cur = obj as Record<string | number, unknown>;
for (let i = 0; i < tokens.length - 1; i++) {
const t = tokens[i]!;
if (cur[t] == null || typeof cur[t] !== 'object') {
if (cur[t] === null || cur[t] === undefined || typeof cur[t] !== 'object') {
cur[t] = typeof tokens[i + 1] === 'number' ? [] : {};
}
cur = cur[t] as Record<string | number, unknown>;

View file

@ -21,7 +21,7 @@ const reply = {
// utf8).
bulk: (v: unknown): Buffer => {
if (v === undefined || v === null) return Buffer.from(NIL);
const b = Buffer.isBuffer(v) ? v : Buffer.from(String(v));
const b = Buffer.isBuffer(v) ? v : Buffer.from(String(v as string));
return Buffer.concat([Buffer.from(`$${b.length}${CRLF}`), b, Buffer.from(CRLF)]);
},
array: (items: unknown[]): Buffer => {
@ -165,7 +165,7 @@ export async function startServer({ dir, port = 6379, host = '127.0.0.1', fsyncP
const server = net.createServer((socket: Socket) => {
const parser = new RespParser();
socket.on('data', (chunk: Buffer) => {
(async () => {
void (async () => {
try {
for (const args of parser.feed(chunk)) {
const res = await handle(db, args);

View file

@ -76,8 +76,8 @@ export class SkipList<K = number, V = string> {
}
insert(key: K, val: V): SkipNode<K, V> {
const update = new Array<SkipNode<K, V>>(MAX_LEVEL);
const rank = new Array<number>(MAX_LEVEL).fill(0);
const update = Array.from<SkipNode<K, V>>({ length: MAX_LEVEL });
const rank = Array.from({ length: MAX_LEVEL }, () => 0);
let x: SkipNode<K, V> = this.header;
const target = { key, val };
@ -134,7 +134,7 @@ export class SkipList<K = number, V = string> {
}
delete(key: K, val: V): boolean {
const update = new Array<SkipNode<K, V>>(MAX_LEVEL);
const update = Array.from<SkipNode<K, V>>({ length: MAX_LEVEL });
let x: SkipNode<K, V> = this.header;
const target = { key, val };
for (let i = this.level - 1; i >= 0; i--) {

View file

@ -44,7 +44,7 @@ export function tokenize(str: unknown): string[] {
}
function stringLeaves(obj: unknown, acc: string[] = []): string[] {
if (obj == null) return acc;
if (obj === null || obj === undefined) return acc;
if (typeof obj === 'string') {
acc.push(obj);
return acc;

View file

@ -71,7 +71,7 @@ export function encodePostingList(entries: readonly (readonly [number, number])[
export function decodePostingList(buf: Buffer): [number, number][] {
const cur = { i: 0 };
const count = decodeVarint(buf, cur);
const out: [number, number][] = new Array(count);
const out = Array.from<[number, number]>({ length: count });
let prev = 0;
for (let k = 0; k < count; k++) {
const d = decodeVarint(buf, cur);

View file

@ -85,7 +85,7 @@ export class WAL {
this.queuedBytes += frame.length;
if (!this.flushing && !this.scheduled) {
this.scheduled = true;
setImmediate(() => this.flushBatch());
setImmediate(() => { void this.flushBatch(); });
}
});
return { offset, done };
@ -142,7 +142,7 @@ export class WAL {
this.inflight = null;
if (this.queue.length > 0 && !this.closed) {
this.scheduled = true;
setImmediate(() => this.flushBatch());
setImmediate(() => { void this.flushBatch(); });
}
}
};

View file

@ -78,8 +78,8 @@ test('crc mismatch throws CorruptFrameError with offset', () => {
test('finish() reports a torn trailing partial frame at the valid-data offset', () => {
const p = new FrameParser();
const good = encodeFrame({ type: TYPE_SET, key: B('ok'), value: B('v') });
[...p.feed(good)];
[...p.feed(Buffer.from([0x4d, 0x44, 0x01]))]; // magic + type: incomplete header
void [...p.feed(good)];
void [...p.feed(Buffer.from([0x4d, 0x44, 0x01]))]; // magic + type: incomplete header
assert.throws(
() => p.finish(),
(e) => e instanceof CorruptFrameError && e.offset === good.length,
@ -90,7 +90,7 @@ test('finish() returns the clean EOF offset when there is no leftover', () => {
const p = new FrameParser();
const a = encodeFrame({ type: TYPE_SET, key: B('a'), value: B('1') });
const b = encodeFrame({ type: TYPE_SET, key: B('b'), value: B('2') });
[...p.feed(Buffer.concat([a, b]))];
void [...p.feed(Buffer.concat([a, b]))];
assert.equal(p.finish(), a.length + b.length);
});

View file

@ -8,7 +8,7 @@
// fresh import of compaction.ts picks up that test's mocked fs.
import assert from 'node:assert/strict';
import { afterEach, test, vi } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
interface MockHandle {
read?: (...args: unknown[]) => Promise<{ bytesRead: number }>;
@ -64,7 +64,7 @@ test('copyFileRange tolerates a source close() failure (best-effort close)', asy
});
const { copyFileRange } = await import('../src/compaction.js');
// The source close failure is swallowed; the copy itself succeeds.
await copyFileRange('/tmp/src', '/tmp/dst', 0, 1);
await expect(copyFileRange('/tmp/src', '/tmp/dst', 0, 1)).resolves.toBeUndefined();
});
test('copyFileRange tolerates a destination close() failure (best-effort close)', async () => {
@ -83,7 +83,7 @@ test('copyFileRange tolerates a destination close() failure (best-effort close)'
const { copyFileRange } = await import('../src/compaction.js');
// Empty range (start===end) → no writes, only a dst.sync() then a failing
// dst.close() that must be swallowed.
await copyFileRange('/tmp/src', '/tmp/dst', 0, 0);
await expect(copyFileRange('/tmp/src', '/tmp/dst', 0, 0)).resolves.toBeUndefined();
});
test('fsyncDir swallows a sync() failure and still closes the handle', async () => {

View file

@ -9,7 +9,7 @@
// their dynamic imports + query-string cache busting do not distort the
// coverage report of this file (Node's coverage keys scripts by URL).
import { test } from 'vitest';
import { expect, test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
@ -142,7 +142,7 @@ test('copyFileRange rejects end < start', async () => {
test('fsyncDir syncs an existing directory without throwing', async () => {
const dir = await tmpDir();
try {
await fsyncDir(dir); // should not throw
await expect(fsyncDir(dir)).resolves.toBeUndefined();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
@ -152,5 +152,5 @@ test('fsyncDir swallows errors when the directory cannot be opened', async () =>
// A non-existent path makes fs.open reject → the catch branch runs and the
// finally closes nothing (fh is null). Best-effort semantics.
const bogus = path.join(os.tmpdir(), `minidb-no-such-dir-${Date.now()}`);
await fsyncDir(bogus); // must not throw
await expect(fsyncDir(bogus)).resolves.toBeUndefined();
});

View file

@ -3,7 +3,7 @@
// Fault-injection-only branches (writev short-write, fsync failure, >64MB
// RESP payload, cross-user EPERM, process-exit hook) are intentionally not
// covered here — see the coverage summary in the commit message.
import { test } from 'vitest';
import { expect, test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
@ -106,10 +106,10 @@ test('WAL append after close rejects', async () => {
test('WAL open and close are idempotent', async () => {
const dir = await tmpDir();
const wal = new WAL(path.join(dir, 'db.wal'), { fsyncPolicy: 'everysec' });
await wal.open();
await wal.open(); // second open is a no-op
await wal.close();
await wal.close(); // second close is a no-op
await expect(wal.open()).resolves.toBeUndefined();
await expect(wal.open()).resolves.toBeUndefined(); // second open is a no-op
await expect(wal.close()).resolves.toBeUndefined();
await expect(wal.close()).resolves.toBeUndefined(); // second close is a no-op
await fs.rm(dir, { recursive: true, force: true });
});

View file

@ -4,7 +4,7 @@
// reference model, and assert they always agree. Seeded so any failure is
// reproducible by re-running with the same seed.
import { test } from 'vitest';
import { expect, test } from 'vitest';
import assert from 'node:assert/strict';
import { MiniDb } from '../../src/index.js';
import { Model } from './helpers/model.js';
@ -61,6 +61,6 @@ async function runSeed(seed, steps) {
test('fuzz-model: random op sequences match a reference model (many seeds)', async () => {
const seeds = [1, 2, 3, 42, 12345, 99999, 0xdeadbeef, 0xc0ffee, 777, 20240625];
for (const seed of seeds) {
await runSeed(seed, 500);
await expect(runSeed(seed, 500)).resolves.toBeUndefined();
}
}, 60_000);

View file

@ -1,5 +1,5 @@
// Covers the RESP commands and parser paths not exercised by server.test.ts.
import { test } from 'vitest';
import { expect, test } from 'vitest';
import assert from 'node:assert/strict';
import net from 'node:net';
import fs from 'node:fs/promises';
@ -116,7 +116,7 @@ test('RESP: QUIT closes the connection', async () => {
const srv = await startServer({ dir, port: 0, fsyncPolicy: 'no' });
try {
const sock = await connect(srv.port);
await sendUntilClose(sock, encode('QUIT'));
await expect(sendUntilClose(sock, encode('QUIT'))).resolves.toBeUndefined();
} finally {
await srv.close();
await fs.rm(dir, { recursive: true, force: true });

View file

@ -52,11 +52,11 @@ describe('Session.listBackgroundTasks / getBackgroundTaskOutput', () => {
const session = await harness.createSession({ id: 'ses_bg_empty_id', workDir });
await expect(session.getBackgroundTaskOutput('')).rejects.toMatchObject({
name: 'KimiError',
code: 'background.task_id_empty',
code: 'task.task_id_empty',
} satisfies Partial<KimiError>);
await expect(session.stopBackgroundTask('')).rejects.toMatchObject({
name: 'KimiError',
code: 'background.task_id_empty',
code: 'task.task_id_empty',
} satisfies Partial<KimiError>);
} finally {
await harness.close();

View file

@ -106,6 +106,8 @@ describe('Event public types', () => {
case 'compaction.blocked':
case 'compaction.cancelled':
case 'compaction.completed':
case 'task.started':
case 'task.terminated':
case 'background.task.started':
case 'background.task.terminated':
case 'cron.fired':

View file

@ -41,7 +41,6 @@ export type {
QuestionsResource,
InteractionsResource,
SessionWorkspaceResource,
AgentFsResource,
SessionFsResource,
} from './resources/session.js';
export type {

View file

@ -39,16 +39,16 @@ export async function resolveKimiHome(homeDir?: string): Promise<string> {
typeof process !== 'undefined' ? process.env?.['KIMI_CODE_HOME'] : undefined;
if (envHome !== undefined && envHome.length > 0) return envHome;
if (!isNode()) return `.${'kimi-code'}`;
const [{ homedir }, { join }] = await Promise.all([import('node:os'), import('node:path')]);
return join(homedir(), `.${'kimi-code'}`);
const [{ homedir }, path] = await Promise.all([import('node:os'), import('node:path')]);
return path.join(homedir(), `.${'kimi-code'}`);
}
/** Absolute path of the persistent token file for a given home dir. */
export async function serverTokenPath(homeDir?: string): Promise<string> {
const home = await resolveKimiHome(homeDir);
if (!isNode()) return `${home}/${SERVER_TOKEN_FILE}`;
const { join } = await import('node:path');
return join(home, SERVER_TOKEN_FILE);
const path = await import('node:path');
return path.join(home, SERVER_TOKEN_FILE);
}
/**

View file

@ -17,7 +17,7 @@ import {
loadLocalServerToken,
resolveKimiHome,
serverTokenPath,
} from '../../src/v2/index.js';
} from '../../src/v2/token.js';
describe('local token discovery', () => {
let home: string | undefined;

View file

@ -63,7 +63,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void
file_upload: true as const,
fs_query: true as const,
mcp: true as const,
background_tasks: true as const,
tasks: true as const,
terminal: true as const,
}),
server_id: opts.serverId,

View file

@ -117,7 +117,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
file_upload: true,
fs_query: true,
mcp: true,
background_tasks: true,
tasks: true,
terminal: true,
});
expect(ulidRegex.test(parsed.server_id)).toBe(true);

View file

@ -23,7 +23,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, assert, beforeEach, describe, expect, it } from 'vitest';
import {
oauthFlowSnapshotSchema,
@ -187,6 +187,7 @@ describe('POST /api/v1/oauth/login (P2.7)', () => {
expect(env.code).toBe(0);
const data = oauthFlowStartSchema.parse(env.data);
expect(data.flow_id).toBe('oauth_01ABCDEFGH');
assert(data.status === 'pending');
expect(data.verification_uri_complete).toBe(
'https://example.com/device?user_code=KIMI-1234',
);

14
pnpm-lock.yaml generated
View file

@ -75,6 +75,9 @@ importers:
'@moonshot-ai/acp-adapter':
specifier: workspace:^
version: link:../../packages/acp-adapter
'@moonshot-ai/agent-core-v2':
specifier: workspace:^
version: link:../../packages/agent-core-v2
'@moonshot-ai/kap-server':
specifier: workspace:^
version: link:../../packages/kap-server
@ -643,7 +646,7 @@ importers:
specifier: workspace:^
version: link:../protocol
bcryptjs:
specifier: 2.4.3
specifier: ^2.4.3
version: 2.4.3
fastify:
specifier: ^5.1.0
@ -651,24 +654,21 @@ importers:
pino:
specifier: ^9.5.0
version: 9.14.0
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
smol-toml:
specifier: 1.6.1
specifier: ^1.6.1
version: 1.6.1
ulid:
specifier: ^3.0.1
version: 3.0.2
ws:
specifier: ^8.20.0
specifier: ^8.18.0
version: 8.20.0
zod:
specifier: 'catalog:'
version: 4.3.6
devDependencies:
'@types/bcryptjs':
specifier: 2.4.6
specifier: ^2.4.6
version: 2.4.6
'@types/ws':
specifier: ^8.18.0