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 => ({ summary: '요약', explanation: '설명', requestedModules: [], requestedLogs: [], requestedDocs: [], actionPlan: [], commands: [], changes: [], createModule: null, ...extra }); const result = (extra: Partial = {}): 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): AgentPlan; groundToolPlan(plan: AgentPlan, dto: CreateAgentPlanDto, results: AgentToolResult[]): AgentPlan; parseToolResults(results: unknown): AgentToolResult[]; isUnexecutablePlan(plan: AgentPlan): boolean; parseSyncedMessage(message: Record, index: number): { plan: AgentPlan }; toStoredActions(message: unknown): Array>; 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'); }); });