kimi-code/packages/services/test/coreProcessService.test.ts
haozhe.yang 18224d47ff refactor(services,daemon,agent-core): align service registry bootstrap and expand DI lifecycle primitives
- remove defaultServicesModule() and services/src/module.ts; consume getSingletonServiceDescriptors() directly\n- update daemon service registrations and bootstrap to use registry descriptors\n- add DisposableMap, DisposableSet, disposable tracking, and disposeOnReturn to agent-core DI\n- update AGENTS.md with new registration patterns
2026-06-11 10:14:45 +08:00

280 lines
8.4 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
SyncDescriptor,
Emitter,
getSingletonServiceDescriptors,
type ApprovalRequest,
type ApprovalResponse,
type Event,
type QuestionRequest,
type QuestionResult,
} from '@moonshot-ai/agent-core';
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
import {
BridgeClientAPI,
CoreProcessService,
IApprovalService,
IEnvironmentService,
IEventService,
ICoreProcessService,
IQuestionService,
} from '../src';
class RecordingEventService implements IEventService {
readonly _serviceBrand: undefined;
readonly events: Event[] = [];
private readonly _emitter = new Emitter<Event>();
readonly onDidPublish = this._emitter.event;
publish(event: Event): void {
this.events.push(event);
this._emitter.fire(event);
}
}
class RecordingApprovalService implements IApprovalService {
readonly _serviceBrand: undefined;
readonly received: ApprovalRequest[] = [];
readonly resolveCalls: Array<{ id: string; response: ApprovalResponse }> = [];
async request(
req: ApprovalRequest & { sessionId: string; agentId: string },
): Promise<ApprovalResponse> {
this.received.push(req);
return { decision: 'approved' };
}
resolve(id: string, response: ApprovalResponse): void {
this.resolveCalls.push({ id, response });
}
}
class RecordingQuestionService implements IQuestionService {
readonly _serviceBrand: undefined;
readonly received: QuestionRequest[] = [];
readonly resolveCalls: Array<{ id: string; response: QuestionResult }> = [];
readonly dismissCalls: string[] = [];
async request(
req: QuestionRequest & { sessionId: string; agentId: string },
): Promise<QuestionResult> {
this.received.push(req);
return null;
}
resolve(id: string, response: QuestionResult): void {
this.resolveCalls.push({ id, response });
}
dismiss(id: string): void {
this.dismissCalls.push(id);
}
}
let tmpHome: string;
let prevHome: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'kimi-services-test-'));
prevHome = process.env['KIMI_HOME'];
process.env['KIMI_HOME'] = tmpHome;
});
afterEach(() => {
if (prevHome === undefined) {
delete process.env['KIMI_HOME'];
} else {
process.env['KIMI_HOME'] = prevHome;
}
try {
rmSync(tmpHome, { recursive: true, force: true });
} catch {
}
});
function makePeers() {
return {
eventService: new RecordingEventService(),
approvalService: new RecordingApprovalService(),
questionService: new RecordingQuestionService(),
};
}
function makeEnv(homeDir: string): IEnvironmentService {
return {
_serviceBrand: undefined,
homeDir,
configPath: join(homeDir, 'config.toml'),
};
}
describe('BridgeClientAPI', () => {
it('routes emitEvent / requestApproval / requestQuestion / toolCall to peer services', async () => {
const { eventService, approvalService, questionService } = makePeers();
const api = new BridgeClientAPI({ eventService, approvalService, questionService });
const ev: Event = {
type: 'agent_status_updated',
sessionId: 'sess-1',
agentId: 'main',
status: { state: 'idle' },
} as unknown as Event;
api.emitEvent(ev);
expect(eventService.events).toEqual([ev]);
const approvalReq = {
toolCallId: 'tc-1',
toolName: 'shell.run',
action: 'execute',
display: { kind: 'generic', summary: 'do thing' } as ApprovalRequest['display'],
sessionId: 'sess-1',
agentId: 'main',
};
const approvalResp = await api.requestApproval(approvalReq);
expect(approvalResp).toEqual({ decision: 'approved' });
expect(approvalService.received).toHaveLength(1);
const questionReq = {
questions: [{ question: '?', options: [{ label: 'A' }] }],
sessionId: 'sess-1',
agentId: 'main',
};
const questionResp = await api.requestQuestion(questionReq);
expect(questionResp).toBeNull();
expect(questionService.received).toHaveLength(1);
const toolResp = await api.toolCall({
toolCallId: 'tc-2',
args: {},
sessionId: 'sess-1',
agentId: 'main',
});
expect(toolResp.isError).toBe(true);
expect(toolResp.output).toMatch(/SDK custom tool calls are not supported/);
});
});
describe('CoreProcessService direct construction', () => {
it('constructs, exposes a callable rpc proxy, and ready() resolves', async () => {
const { eventService, approvalService, questionService } = makePeers();
const core = new CoreProcessService(
{},
makeEnv(tmpHome),
eventService,
approvalService,
questionService,
);
try {
await expect(core.ready()).resolves.toBeUndefined();
expect(typeof core.rpc.getCoreInfo).toBe('function');
} finally {
core.dispose();
}
});
it('rpc round-trip through createRPC reaches KimiCore (getCoreInfo smoke)', async () => {
const { eventService, approvalService, questionService } = makePeers();
const core = new CoreProcessService(
{},
makeEnv(tmpHome),
eventService,
approvalService,
questionService,
);
try {
await core.ready();
const info = await core.rpc.getCoreInfo({});
expect(info).toHaveProperty('version');
expect(typeof info.version).toBe('string');
} finally {
core.dispose();
}
});
it('dispose is idempotent and short-circuits subsequent rpc calls', async () => {
const { eventService, approvalService, questionService } = makePeers();
const core = new CoreProcessService(
{},
makeEnv(tmpHome),
eventService,
approvalService,
questionService,
);
await core.ready();
core.dispose();
core.dispose();
await expect(core.rpc.getCoreInfo({})).rejects.toThrow(/disposed/);
});
it('default-wires a resolveOAuthTokenProvider when caller omits one', () => {
const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml'));
expect(typeof resolver).toBe('function');
const tokenProvider = resolver('managed:kimi-code');
expect(tokenProvider).toBeDefined();
expect(typeof tokenProvider?.getAccessToken).toBe('function');
});
it('default-wires kimiRequestHeaders from identity when caller omits headers', () => {
const headers = CoreProcessService._defaultKimiRequestHeaders(
tmpHome,
{ userAgentProduct: 'kimi-code-cli', version: '9.9.9' },
);
expect(headers).toBeDefined();
expect(headers!['User-Agent']).toMatch(/^kimi-code-cli\/9\.9\.9/);
expect(headers!['X-Msh-Platform']).toBe('kimi_code_cli');
expect(headers!['X-Msh-Version']).toBe('9.9.9');
expect(headers!['X-Msh-Device-Id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('returns undefined headers when no identity is provided (back-compat)', () => {
const headers = CoreProcessService._defaultKimiRequestHeaders(tmpHome);
expect(headers).toBeUndefined();
});
it('caller-supplied kimiRequestHeaders win over identity-derived defaults', () => {
const explicit = { 'User-Agent': 'override/1.0' };
const picked =
explicit ?? CoreProcessService._defaultKimiRequestHeaders(
tmpHome,
{ userAgentProduct: 'kimi-code-cli', version: '9.9.9' },
);
expect(picked).toBe(explicit);
});
});
describe('singleton registry composition', () => {
it('returns a CoreProcessService descriptor that composes with the DI container', async () => {
const { eventService, approvalService, questionService } = makePeers();
const moduleEntries = getSingletonServiceDescriptors();
expect(moduleEntries.length).toBeGreaterThanOrEqual(1);
expect(moduleEntries[0]![0]).toBe(ICoreProcessService);
expect(moduleEntries[0]![1]).toBeInstanceOf(SyncDescriptor);
const ix = new TestInstantiationService();
for (const [id, desc] of moduleEntries) {
ix.set(id, desc);
}
ix.stub(IEventService, eventService);
ix.stub(IApprovalService, approvalService);
ix.stub(IQuestionService, questionService);
ix.stub(IEnvironmentService, makeEnv(tmpHome));
try {
const core = ix.createInstance(CoreProcessService, {});
try {
await core.ready();
expect(typeof core.rpc.getCoreInfo).toBe('function');
} finally {
core.dispose();
}
} finally {
ix.dispose();
}
});
});