import { createExecuteSkillTool } from '../src/modules/netaclaw/tools/builtin/execute_skill'; describe('execute_skill process event bridge', () => { it('bridges skill process events to tool updates', async () => { let capturedParams: any; const executor = { execute: jest.fn(async (params: any) => { capturedParams = params; params.onProcessEvent?.({ source: 'skill', targetType: 'skill', stage: 'frames', status: 'running', message: 'frames extracted', current: 1, total: 2, payload: { frameCount: 10 }, }); return { success: true, output: { ok: true }, duration: 1 }; }), }; const updates: any[] = []; const tool = createExecuteSkillTool(executor as any); const result = await tool.execute( 'call-1', { name: 'demo', input: { taskId: 't1' } }, event => updates.push(event), ); expect(capturedParams).toEqual(expect.objectContaining({ skillName: 'demo', input: { taskId: 't1' }, toolCallId: 'call-1', })); expect(updates).toEqual([ expect.objectContaining({ source: 'skill', targetType: 'skill', stage: 'frames', status: 'running', message: 'frames extracted', current: 1, total: 2, payload: { frameCount: 10, skillName: 'demo', }, }), ]); expect(result).toEqual({ type: 'text', text: JSON.stringify({ ok: true }, null, 2), }); }); it('does not fail execute_skill when process update observer throws', async () => { const executor = { execute: jest.fn(async (params: any) => { params.onProcessEvent?.({ source: 'skill', targetType: 'skill', status: 'running', message: 'still observational', }); return { success: true, output: { ok: true }, duration: 1 }; }), }; const tool = createExecuteSkillTool(executor as any); const result = await tool.execute( 'call-1', { name: 'demo', input: {} }, () => { throw new Error('observer failed'); }, ); expect(result).toEqual({ type: 'text', text: JSON.stringify({ ok: true }, null, 2), }); }); it('returns structured images when skill output includes bestFrameImages', async () => { const output = { success: true, taskId: 'task_1', summary: { mergedDamageCount: 1, bestFrameCount: 1, candidateDamageCount: 2, reviewImageCount: 1 }, vehicleInfo: { frontPlate: '粤B12345' }, candidates: [ { id: 'cand_001', part: '前保险杠', type: '划痕' }, ], damages: [ { id: 'damage_001', part: '前保险杠', type: '划痕', severity: '轻微' }, ], uncertainDamages: [ { id: 'damage_002', part: '右前门', type: '凹陷', severity: '轻微' }, ], bestFrameImages: [ { url: '/workspace/vehicle-damage-inspection/task_1/source/best_01_damage_001_2_00s.jpg', mimeType: 'image/jpeg', label: '前保险杠划痕 2s', }, ], reviewImages: [ { url: '/workspace/vehicle-damage-inspection/task_1/source/review_zoom_damage_001_2_00s.jpg', mimeType: 'image/jpeg', label: '模型不确定,放大后复核:前保险杠划痕', purpose: '模型不确定,放大后复核', reason: '放大后仍不够清晰,建议人工复核', }, ], }; const executor = { execute: jest.fn(async () => ({ success: true, output, duration: 1 })), }; const tool = createExecuteSkillTool(executor as any); const result = await tool.execute('call-1', { name: 'vehicle-damage-inspection', input: {} }); expect(result).toEqual({ type: 'images', text: [ 'Skill vehicle-damage-inspection 执行完成。', '请只依据 finalResult.damages、finalResult.uncertainDamages、finalResult.bestFrameImages、finalResult.reviewImages 和 finalResult.summary 回答用户。', 'damages 是已确认旧伤;uncertainDamages 是放大后仍无法明确确认的疑似旧伤,需要提示人工复核,不能说成已确认旧伤。', 'reviewImages 是模型不清楚时生成的放大复核图;如存在,请说明“模型不清楚,已放大后判断”,并把图片返回给前端展示。', 'candidates 是已复核前的中间候选,不代表最终旧伤,不要把 candidates 当成检测结论。', JSON.stringify({ success: true, taskId: 'task_1', finalResult: { summary: { mergedDamageCount: 1, bestFrameCount: 1, candidateDamageCount: 2, reviewImageCount: 1 }, vehicleInfo: { frontPlate: '粤B12345' }, damages: [ { id: 'damage_001', part: '前保险杠', type: '划痕', severity: '轻微' }, ], uncertainDamages: [ { id: 'damage_002', part: '右前门', type: '凹陷', severity: '轻微' }, ], bestFrameImages: output.bestFrameImages, reviewImages: output.reviewImages, }, artifacts: undefined, }, null, 2), ].join('\n'), images: [ { url: '/workspace/vehicle-damage-inspection/task_1/source/best_01_damage_001_2_00s.jpg', mimeType: 'image/jpeg', label: '前保险杠划痕 2s', }, { url: '/workspace/vehicle-damage-inspection/task_1/source/review_zoom_damage_001_2_00s.jpg', mimeType: 'image/jpeg', label: '模型不确定,放大后复核:前保险杠划痕', purpose: '模型不确定,放大后复核', reason: '放大后仍不够清晰,建议人工复核', }, ], }); }); it('returns final-only text when reviewed skill output has rejected all candidates', async () => { const output = { success: true, taskId: 'task_empty', summary: { candidateDamageCount: 1, mergedDamageCount: 0, bestFrameCount: 0, reviewImageCount: 0 }, candidates: [ { id: 'cand_001', part: '右前翼子板', type: '凹陷' }, ], damages: [], uncertainDamages: [], bestFrameImages: [], reviewImages: [], }; const executor = { execute: jest.fn(async () => ({ success: true, output, duration: 1 })), }; const tool = createExecuteSkillTool(executor as any); const result = await tool.execute('call-1', { name: 'vehicle-damage-inspection', input: {} }); expect(result).toEqual({ type: 'text', text: [ 'Skill vehicle-damage-inspection 执行完成。', '请只依据 finalResult.damages、finalResult.uncertainDamages、finalResult.bestFrameImages、finalResult.reviewImages 和 finalResult.summary 回答用户。', 'damages 是已确认旧伤;uncertainDamages 是放大后仍无法明确确认的疑似旧伤,需要提示人工复核,不能说成已确认旧伤。', 'reviewImages 是模型不清楚时生成的放大复核图;如存在,请说明“模型不清楚,已放大后判断”,并把图片返回给前端展示。', 'candidates 是已复核前的中间候选,不代表最终旧伤,不要把 candidates 当成检测结论。', JSON.stringify({ success: true, taskId: 'task_empty', finalResult: { summary: { candidateDamageCount: 1, mergedDamageCount: 0, bestFrameCount: 0, reviewImageCount: 0 }, vehicleInfo: undefined, damages: [], uncertainDamages: [], bestFrameImages: [], reviewImages: [], }, artifacts: undefined, }, null, 2), ].join('\n'), }); expect((result as any).text).not.toContain('右前翼子板'); }); });