Files
lms-be/src/ai/agent-tools.spec.ts
T

91 lines
7.9 KiB
TypeScript

import { createHash } from 'node:crypto';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AgentService } from './agent.service';
import { validateAgentTool, validateAgentOperation, type AgentToolResult } from './agent-tools';
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
import type { AgentPlan } from './ai.types';
const hash = (content: string) => createHash('sha256').update(content).digest('hex');
const plan = (extra: Partial<AgentPlan> = {}): AgentPlan => ({ summary: '요약', explanation: '설명', requestedModules: [], requestedLogs: [], requestedDocs: [], actionPlan: [], commands: [], changes: [], createModule: null, ...extra });
const result = (extra: Partial<AgentToolResult> = {}): AgentToolResult => ({ id: 'query-1', name: 'files.read', moduleName: 'NAV2-Control', status: 'ok', observedAt: new Date().toISOString(), target: 'ssh:test', truncated: false, data: JSON.stringify({ path: 'config/control.yaml', hash: hash('steering: ackermann'), complete: true, content: 'steering: ackermann', exists: true }), ...extra });
const dto: CreateAgentPlanDto = { courseId: 'a1b2c3d4-e5f6-4789-8123-123456789abc', request: '분석', toolProtocol: 1, environment: 'remote', modules: [{ name: 'NAV2-Control', parameters: ['sim_mode:=false'], files: [] }] };
type Harness = {
parsePlan(content: string, modules: Set<string>): AgentPlan;
groundToolPlan(plan: AgentPlan, dto: CreateAgentPlanDto, results: AgentToolResult[]): AgentPlan;
parseToolResults(results: unknown): AgentToolResult[];
isUnexecutablePlan(plan: AgentPlan): boolean;
parseSyncedMessage(message: Record<string, unknown>, index: number): { plan: AgentPlan };
toStoredActions(message: unknown): Array<Record<string, unknown>>;
toSessionResponse(session: unknown): { messages: Array<{ plan: AgentPlan }> };
};
const service = Object.create(AgentService.prototype) as Harness;
describe('structured agent tools', () => {
it('validates tool protocol, observations and module parameters in the request DTO', async () => {
const value = plainToInstance(CreateAgentPlanDto, { ...dto, toolResults: [result()] });
expect(await validate(value, { whitelist: true, forbidNonWhitelisted: true })).toEqual([]);
const invalid = plainToInstance(CreateAgentPlanDto, { ...dto, toolResults: [result({ name: 'files.write' as never })] });
expect((await validate(invalid, { whitelist: true, forbidNonWhitelisted: true })).length).toBeGreaterThan(0);
});
it('accepts precise source requests but rejects unsafe paths and arbitrary runtime arguments', () => {
expect(validateAgentTool({ id: 'search', name: 'files.search', moduleName: 'NAV2-Control', args: { query: 'steering', path: 'src' } }).name).toBe('files.search');
expect(() => validateAgentTool({ id: 'read', name: 'files.read', moduleName: 'NAV2-Control', args: { path: '../secret' } })).toThrow();
expect(() => validateAgentTool({ id: 'read', name: 'network.inspect', moduleName: 'NAV2-Control', args: { command: 'reboot' } } as never)).toThrow();
expect(() => validateAgentOperation({ name: 'modules.start', command: 'reboot' } as never)).toThrow();
});
it('does not confuse a tool request with a plan-only response and rejects mixed reads and writes', () => {
const parsed = service.parsePlan(JSON.stringify(plan({ actionPlan: ['설정 조회'], toolRequests: [{ id: 'search', name: 'files.search', moduleName: 'NAV2-Control', args: { query: 'steering' } }] })), new Set(['NAV2-Control']));
expect(service.isUnexecutablePlan(parsed)).toBe(false);
expect(() => service.parsePlan(JSON.stringify({ ...parsed, commands: [{ id: 'start', moduleName: 'NAV2-Control', purpose: '시작', operation: { name: 'modules.start' } }] }), new Set(['NAV2-Control']))).toThrow(/컨텍스트/);
expect(() => service.parsePlan(JSON.stringify(plan({ toolRequests: [{ id: 'read', name: 'files.list', moduleName: 'Other' }] })), new Set(['NAV2-Control']))).toThrow(/존재하지/);
});
it('generates the displayed command from validated operation metadata, not model shell text', () => {
const parsed = service.parsePlan(JSON.stringify(plan({ commands: [{ id: 'build', moduleName: 'NAV2-Control', purpose: '빌드', command: 'sudo reboot', operation: { name: 'modules.build' } }] })), new Set(['NAV2-Control']));
expect(parsed.commands[0].command).toBe('lms modules.build');
expect(service.groundToolPlan(parsed, dto, []).commands[0].operation?.name).toBe('modules.build');
expect(() => service.groundToolPlan(plan({ commands: [{ id: 'shell', moduleName: 'NAV2-Control', purpose: '셸', command: './run.sh' }] }), dto, [])).toThrow(/구조화된/);
});
it('requires a complete successful original read and replaces any model-supplied hash', () => {
const change = { moduleName: 'NAV2-Control', path: 'config/control.yaml', content: 'steering: tricycle', reason: '설정 수정', expectedHash: '0'.repeat(64) };
expect(service.groundToolPlan(plan({ changes: [change] }), dto, [result()]).changes[0].expectedHash).toBe(hash('steering: ackermann'));
for (const evidence of [[], [result({ status: 'error' })], [result({ truncated: true })], [result({ data: JSON.stringify({ path: change.path, hash: null, complete: false }) })]]) {
expect(() => service.groundToolPlan(plan({ changes: [change] }), dto, evidence)).toThrow(/파일 전체/);
}
});
it('allows a new file only with explicit missing-file evidence and protects redacted secrets', () => {
const change = { moduleName: 'NAV2-Control', path: 'config/new.yaml', content: 'enabled: true', reason: '새 설정' };
const evidence = result({ data: JSON.stringify({ path: change.path, hash: null, exists: false, complete: true, content: '' }) });
expect(service.groundToolPlan(plan({ changes: [change] }), dto, [evidence]).changes[0].expectedHash).toBeNull();
expect(() => service.groundToolPlan(plan({ changes: [{ ...change, content: 'password: [REDACTED]' }] }), dto, [evidence])).toThrow(/비밀/);
});
it('redacts observations without corrupting JSON and enforces aggregate budgets', () => {
const parsed = service.parseToolResults([result({ data: JSON.stringify({ password: 'private', logs: 'API_KEY=secret-value\nready' }) })]);
expect(JSON.parse(parsed[0].data)).toEqual({ password: '[REDACTED]', logs: 'API_KEY=[REDACTED]\nready' });
expect(() => service.parseToolResults([result({ data: 'invalid' })])).toThrow(/JSON/);
expect(() => service.parseToolResults(Array.from({ length: 4 }, (_, index) => result({ id: `r-${index}`, data: JSON.stringify('x'.repeat(50_000)) })))).toThrow(/용량/);
});
it('persists and restores operation metadata, file hashes and read evidence in existing action payloads', () => {
const message = service.parseSyncedMessage({ id: 'agent-1', role: 'assistant', content: '요약', plan: plan({
commands: [{ id: 'build', moduleName: 'NAV2-Control', command: 'lms modules.build', purpose: '빌드', operation: { name: 'modules.build' } }],
changes: [{ moduleName: 'NAV2-Control', path: 'config/control.yaml', content: 'changed', reason: '수정', expectedHash: hash('old') }],
evidence: [result()],
}) }, 0);
const actions = service.toStoredActions(message);
expect(actions.find(action => action.type === 'COMMAND')?.payload).toEqual({ operation: { name: 'modules.build' } });
expect(actions.find(action => action.type === 'FILE_CHANGE')?.payload).toEqual({ expectedHash: hash('old') });
expect(actions.find(action => action.type === 'TOOL_RESULT')?.payload).toMatchObject({ name: 'files.read', target: 'ssh:test' });
const restored = service.toSessionResponse({ id: 'session', messages: [{ clientMessageId: 'agent-1', role: 'assistant', content: '요약', summary: '요약', explanation: '설명', actionPlan: [], actions }] }).messages[0].plan;
expect(restored.commands[0].operation?.name).toBe('modules.build');
expect(restored.changes[0].expectedHash).toBe(hash('old'));
expect(restored.evidence?.[0].name).toBe('files.read');
});
});