v2.2.292-296: 메모리·발열 최적화 + Pixel Office 완전 제거 + 소형 모델 추론 강화(calculate/run_code/지식 스코프)
- v2.2.292 메모리·발열: 헬스체크 워크스페이스 쓰기→fs.access(워처 연쇄 제거)·git 검사 비동기 30분 주기, 웹뷰 retainContextWhenHidden 정리(채팅만 유지), 브레인 인덱스 유휴 30분 TTL 해제, 채팅 DOM 200개 상한, @lmstudio/sdk 지연 로드 - v2.2.293 Pixel Office 시각화 폐기: astraOffice 모듈·사이드바 매니저 3종·스프라이트 17MB 삭제, sidebarProvider collector 섹션 통삭제 (기업 모드 판단 로직 무변경, vsix 12.2MB→1.3MB) - v2.2.294 도구 메뉴 'NotebookLM 백엔드 실행/종료' 버튼: OS 자동 감지, 프로젝트 자동 탐색+폴더 선택 저장, 포트 정리 폴백 (터미널 없이 Research 백엔드 켜고 끄기) - v2.2.295 추론 강화 1탄: [ACTION 16] <calculate> Python 계산 위임(결과 재주입·자가수정 루프), 단계별 지식 스코프(General+Specialty, 결정적 도메인 분류, 설정 지식·기억 탭 UI) - v2.2.296 추론 강화 2탄: [ACTION 17] <run_code> 실행 확인(stdout 회수→수정→재실행 루프), 문법 오류 재주입 자가수정, 도구 라우팅 힌트(dynamicBlocks), Grounding rescue 도메인 스코프 확장 - 검증: jest 818 통과(신규 29개), tsc 무오류, esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* bridgeLauncher — NotebookLM 로컬 백엔드 원클릭 실행/종료의 순수 로직 검증.
|
||||
* (터미널 생성·프로세스 kill 같은 부수효과는 VS Code/OS 의존이라 여기선 순수 함수만.)
|
||||
*/
|
||||
import * as path from 'path';
|
||||
import { candidateProjectDirs, buildPortKillCommand } from '../src/features/datacollect/bridgeLauncher';
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
workspace: { getConfiguration: jest.fn(), workspaceFolders: [] },
|
||||
window: {},
|
||||
}), { virtual: true });
|
||||
|
||||
describe('candidateProjectDirs — 프로젝트 자동 탐색 후보 순서', () => {
|
||||
it('설정값이 있으면 항상 1순위다', () => {
|
||||
const dirs = candidateProjectDirs('/custom/Datacollector', ['/ws/ConnectAI']);
|
||||
expect(dirs[0]).toBe('/custom/Datacollector');
|
||||
});
|
||||
|
||||
it('워크스페이스 자신 → 부모의 형제 폴더(이름 후보들) 순으로 나열한다', () => {
|
||||
const dirs = candidateProjectDirs('', ['/proj/ConnectAI']);
|
||||
expect(dirs[0]).toBe('/proj/ConnectAI');
|
||||
expect(dirs).toContain(path.join('/proj', 'Datacollector_MAC'));
|
||||
expect(dirs).toContain(path.join('/proj', 'Datacollector'));
|
||||
expect(dirs).toContain(path.join('/proj', 'Datacollect'));
|
||||
});
|
||||
|
||||
it('중복 경로는 한 번만 나온다', () => {
|
||||
const dirs = candidateProjectDirs('/proj/Datacollector_MAC', ['/proj/ConnectAI', '/proj/ConnectAI']);
|
||||
expect(dirs.filter(d => d === path.join('/proj', 'Datacollector_MAC')).length).toBe(1);
|
||||
expect(dirs.filter(d => d === '/proj/ConnectAI').length).toBe(1);
|
||||
});
|
||||
|
||||
it('설정값 공백/빈 문자열은 무시한다', () => {
|
||||
expect(candidateProjectDirs(' ', [])).toEqual([]);
|
||||
expect(candidateProjectDirs('', [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPortKillCommand — OS 자동 감지 분기', () => {
|
||||
it('윈도우는 PowerShell Stop-Process 를 쓴다', () => {
|
||||
const cmd = buildPortKillCommand('win32', 3002);
|
||||
expect(cmd).toContain('powershell');
|
||||
expect(cmd).toContain('-LocalPort 3002');
|
||||
expect(cmd).toContain('Stop-Process');
|
||||
});
|
||||
|
||||
it('맥/리눅스는 lsof + kill 을 쓴다', () => {
|
||||
for (const platform of ['darwin', 'linux'] as NodeJS.Platform[]) {
|
||||
const cmd = buildPortKillCommand(platform, 3002);
|
||||
expect(cmd).toContain('lsof -ti :3002');
|
||||
expect(cmd).toContain('kill');
|
||||
}
|
||||
});
|
||||
|
||||
it('포트 번호가 명령에 반영된다', () => {
|
||||
expect(buildPortKillCommand('win32', 4100)).toContain('4100');
|
||||
expect(buildPortKillCommand('darwin', 4100)).toContain(':4100');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* <calculate> 액션 — 수학·수치 계산의 Python 위임 검증.
|
||||
*
|
||||
* 소형 모델 추론 강화 전략의 1축: "모델은 코드로 변환만, 계산은 도구가".
|
||||
* 실행 결과/에러가 chatHistory 에 internal system 메시지로 재주입되어
|
||||
* agent 의 자동 후속 턴(자가수정 루프)을 트리거하는 계약을 검증한다.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import { applyCalculateActions, _resetPythonCache } from '../src/agent/actions/calculate';
|
||||
|
||||
// vscode 런타임 의존 없음(type-only import) — 별도 mock 불필요.
|
||||
|
||||
function hasPython(): boolean {
|
||||
for (const cmd of ['python3', 'python', 'py']) {
|
||||
try { execFileSync(cmd, ['--version'], { timeout: 3000 }); return true; } catch { /* next */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const PY = hasPython();
|
||||
const itPy = PY ? it : it.skip;
|
||||
|
||||
function makeCtx(aiMessage: string): any {
|
||||
return { aiMessage, report: [], chatHistory: [] };
|
||||
}
|
||||
|
||||
describe('applyCalculateActions', () => {
|
||||
beforeEach(() => _resetPythonCache());
|
||||
|
||||
it('calculate 태그가 없으면 아무것도 하지 않는다', async () => {
|
||||
const ctx = makeCtx('그냥 일반 답변입니다. 2+2=4 입니다.');
|
||||
await applyCalculateActions(ctx);
|
||||
expect(ctx.report).toEqual([]);
|
||||
expect(ctx.chatHistory).toEqual([]);
|
||||
});
|
||||
|
||||
itPy('정상 계산: stdout 이 internal system 메시지로 재주입된다', async () => {
|
||||
const ctx = makeCtx('<calculate>print(1234 * 5678)</calculate>');
|
||||
await applyCalculateActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('Calculated'))).toBe(true);
|
||||
expect(ctx.chatHistory.length).toBe(1);
|
||||
expect(ctx.chatHistory[0].role).toBe('system');
|
||||
expect(ctx.chatHistory[0].internal).toBe(true);
|
||||
expect(ctx.chatHistory[0].content).toContain('[Result of calculate]');
|
||||
expect(ctx.chatHistory[0].content).toContain('7006652'); // 1234*5678
|
||||
});
|
||||
|
||||
itPy('에러 코드: stderr 가 ERROR 블록으로 재주입되어 자가수정 루프를 유도한다', async () => {
|
||||
const ctx = makeCtx('<calculate>print(undefined_variable_xyz)</calculate>');
|
||||
await applyCalculateActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('failed'))).toBe(true);
|
||||
expect(ctx.chatHistory[0].content).toContain('[Result of calculate — ERROR]');
|
||||
expect(ctx.chatHistory[0].content).toContain('다시 emit');
|
||||
});
|
||||
|
||||
itPy('print 누락: 빈 출력이면 print 힌트를 재주입한다', async () => {
|
||||
const ctx = makeCtx('<calculate>x = 40 + 2</calculate>');
|
||||
await applyCalculateActions(ctx);
|
||||
expect(ctx.chatHistory[0].content).toContain('EMPTY OUTPUT');
|
||||
expect(ctx.chatHistory[0].content).toContain('print');
|
||||
});
|
||||
|
||||
itPy('여러 태그는 각각 실행되고, 한도(4개) 초과분은 건너뛴다', async () => {
|
||||
const tags = Array.from({ length: 6 }, (_, i) => `<calculate>print(${i} + 1)</calculate>`).join('\n');
|
||||
const ctx = makeCtx(tags);
|
||||
await applyCalculateActions(ctx);
|
||||
const results = ctx.chatHistory.filter((m: any) => m.content.includes('[Result of calculate]'));
|
||||
expect(results.length).toBe(4);
|
||||
expect(ctx.report.some((r: string) => r.includes('한도'))).toBe(true);
|
||||
});
|
||||
|
||||
it('과도하게 긴 코드는 실행 전 차단한다', async () => {
|
||||
const ctx = makeCtx(`<calculate>${'x=1\n'.repeat(3000)}print(x)</calculate>`);
|
||||
await applyCalculateActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('너무 깁니다'))).toBe(true);
|
||||
expect(ctx.chatHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 단계별 지식 스코프 라우터 — 도메인 분류(휴리스틱)와 폴더 조합 규칙 검증.
|
||||
* 소형 모델 정밀 검색의 핵심 계약: 확신 없으면 'general'(전체 두뇌)로 보수적 폴백.
|
||||
*/
|
||||
import { classifyKnowledgeDomain, combineDomainFolders, buildDomainRoutingHint } from '../src/retrieval/domainRouter';
|
||||
|
||||
jest.mock('vscode', () => ({ workspace: { getConfiguration: jest.fn() } }), { virtual: true });
|
||||
|
||||
describe('classifyKnowledgeDomain', () => {
|
||||
it('코드 펜스가 있으면 coding 확정', () => {
|
||||
expect(classifyKnowledgeDomain('이 코드 왜 안 돼?\n```js\nconsole.log(x)\n```')).toBe('coding');
|
||||
});
|
||||
|
||||
it('코딩 신호: 에러/파일 확장자/언어명', () => {
|
||||
expect(classifyKnowledgeDomain('TypeError: cannot read properties of undefined 에러가 나요')).toBe('coding');
|
||||
expect(classifyKnowledgeDomain('sidebarProvider.ts 파일의 함수를 리팩토링해줘')).toBe('coding');
|
||||
expect(classifyKnowledgeDomain('python으로 API endpoint 구현해줘')).toBe('coding');
|
||||
});
|
||||
|
||||
it('수학 신호: 연산식/백분율/수학 용어', () => {
|
||||
expect(classifyKnowledgeDomain('1234 * 5678 계산해줘')).toBe('math');
|
||||
expect(classifyKnowledgeDomain('원금 500만원에 연 3.5% 복리 이자면 10년 뒤 얼마야?')).toBe('math');
|
||||
expect(classifyKnowledgeDomain('주사위 두 개 던져서 합이 7일 확률은?')).toBe('math');
|
||||
});
|
||||
|
||||
it('팩트 신호: 질문사/정의 요청 (다른 도메인 신호가 없을 때만)', () => {
|
||||
expect(classifyKnowledgeDomain('세종대왕이 누구야?')).toBe('facts');
|
||||
expect(classifyKnowledgeDomain('OLED와 QLED의 차이가 뭐야?')).toBe('facts');
|
||||
});
|
||||
|
||||
it('신호가 없거나 애매하면 general (전체 두뇌 폴백)', () => {
|
||||
expect(classifyKnowledgeDomain('오늘 하루 어땠어?')).toBe('general');
|
||||
expect(classifyKnowledgeDomain('')).toBe('general');
|
||||
expect(classifyKnowledgeDomain('고마워!')).toBe('general');
|
||||
});
|
||||
|
||||
it('코딩+팩트 혼합 신호는 전문 도메인(coding)이 이긴다', () => {
|
||||
expect(classifyKnowledgeDomain('git rebase가 뭐야? 설명해줘')).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineDomainFolders — General+Specialty 조합 규칙', () => {
|
||||
const cfg = { general: 'General', math: 'Specialty_Math', coding: '10_Wiki/Topic_Programming', facts: '' };
|
||||
|
||||
it('도메인 분류 성공 + Specialty 설정됨 → [General, Specialty]', () => {
|
||||
expect(combineDomainFolders('coding', cfg)).toEqual(['General', '10_Wiki/Topic_Programming']);
|
||||
expect(combineDomainFolders('math', cfg)).toEqual(['General', 'Specialty_Math']);
|
||||
});
|
||||
|
||||
it('Specialty 미설정 도메인은 스코프 미적용 (General만으로 좁히지 않음)', () => {
|
||||
expect(combineDomainFolders('facts', cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it('general 도메인은 항상 스코프 미적용 (레거시 전체 검색)', () => {
|
||||
expect(combineDomainFolders('general', cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it('General 미설정이면 Specialty 단독으로 스코프', () => {
|
||||
expect(combineDomainFolders('math', { ...cfg, general: '' })).toEqual(['Specialty_Math']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDomainRoutingHint — 분류 1회로 도구 유도까지', () => {
|
||||
it('math → calculate 위임 지시', () => {
|
||||
expect(buildDomainRoutingHint('math')).toContain('<calculate>');
|
||||
expect(buildDomainRoutingHint('math')).toContain('암산');
|
||||
});
|
||||
it('coding → run_code 실행 확인 + edit_file 수정 루프 지시', () => {
|
||||
const hint = buildDomainRoutingHint('coding');
|
||||
expect(hint).toContain('<run_code');
|
||||
expect(hint).toContain('<edit_file>');
|
||||
});
|
||||
it('facts → 근거 없으면 확인 불가 / fetch_url', () => {
|
||||
const hint = buildDomainRoutingHint('facts');
|
||||
expect(hint).toContain('확인 불가');
|
||||
expect(hint).toContain('<fetch_url>');
|
||||
});
|
||||
it('general → 힌트 없음 (기존 동작 무간섭)', () => {
|
||||
expect(buildDomainRoutingHint('general')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import {
|
||||
validateOfficeSnapshot,
|
||||
makeEmptyOfficeSnapshot,
|
||||
} from '../src/features/astraOffice/schema';
|
||||
import {
|
||||
validateLayout,
|
||||
migrateLayout,
|
||||
} from '../src/features/astraOffice/view/layoutSchema';
|
||||
import { presentOfficeSnapshot, normalizeAgentId } from '../src/features/astraOffice/presenter';
|
||||
|
||||
describe('OfficeSnapshot schema', () => {
|
||||
test('makeEmptyOfficeSnapshot returns idle / null active', () => {
|
||||
const s = makeEmptyOfficeSnapshot();
|
||||
expect(s.phase).toBe('idle');
|
||||
expect(s.activeAgentId).toBeNull();
|
||||
expect(s.roster).toEqual([]);
|
||||
expect(s.activity).toEqual([]);
|
||||
expect(s.newBubbles).toEqual([]);
|
||||
});
|
||||
|
||||
test('validateOfficeSnapshot rejects null / non-object', () => {
|
||||
expect(validateOfficeSnapshot(null)).toBeNull();
|
||||
expect(validateOfficeSnapshot(undefined)).toBeNull();
|
||||
expect(validateOfficeSnapshot('idle')).toBeNull();
|
||||
expect(validateOfficeSnapshot(42)).toBeNull();
|
||||
});
|
||||
|
||||
test('validateOfficeSnapshot fills defaults for sparse input', () => {
|
||||
const s = validateOfficeSnapshot({});
|
||||
expect(s).not.toBeNull();
|
||||
expect(s!.phase).toBe('idle');
|
||||
expect(s!.activeAgentId).toBeNull();
|
||||
expect(s!.roster).toEqual([]);
|
||||
expect(typeof s!.updatedAt).toBe('number');
|
||||
});
|
||||
|
||||
test('validateOfficeSnapshot rejects invalid enums quietly', () => {
|
||||
const s = validateOfficeSnapshot({
|
||||
phase: 'totally_made_up_phase',
|
||||
activeAgentId: 'ceo',
|
||||
roster: [{ agentId: 'ceo', agentName: 'CEO', roleCategory: 'BOGUS', status: 'NOT_REAL', lastActivityAt: 100 }],
|
||||
});
|
||||
expect(s!.phase).toBe('idle'); // fell back
|
||||
expect(s!.roster[0].roleCategory).toBe('support'); // fell back
|
||||
expect(s!.roster[0].status).toBe('idle'); // fell back
|
||||
});
|
||||
|
||||
test('validateOfficeSnapshot keeps valid roster + bubbles', () => {
|
||||
const s = validateOfficeSnapshot({
|
||||
phase: 'executing',
|
||||
activeAgentId: 'developer',
|
||||
roster: [
|
||||
{ agentId: 'developer', agentName: '개발', roleCategory: 'developer', status: 'executing', lastActivityAt: 100 },
|
||||
{ agentId: 'inspector', agentName: '감리', roleCategory: 'inspector', status: 'idle', lastActivityAt: 90 },
|
||||
],
|
||||
newBubbles: [
|
||||
{ agentId: 'developer', text: '코드 들어간다', type: 'event' },
|
||||
{ agentId: 'inspector', text: 'should_be_dropped', type: 'wrong_type' }, // type 잘못 → 'status' 로 폴백
|
||||
],
|
||||
});
|
||||
expect(s!.phase).toBe('executing');
|
||||
expect(s!.activeAgentId).toBe('developer');
|
||||
expect(s!.roster).toHaveLength(2);
|
||||
expect(s!.newBubbles).toHaveLength(2);
|
||||
expect(s!.newBubbles[1].type).toBe('status'); // fell back
|
||||
});
|
||||
|
||||
test('validateOfficeSnapshot drops malformed roster entries', () => {
|
||||
const s = validateOfficeSnapshot({
|
||||
phase: 'idle',
|
||||
roster: [
|
||||
{ agentId: 'ok', roleCategory: 'ceo', status: 'idle', lastActivityAt: 0 },
|
||||
{ /* no agentId */ roleCategory: 'ceo' },
|
||||
null,
|
||||
'string',
|
||||
],
|
||||
});
|
||||
expect(s!.roster).toHaveLength(1);
|
||||
expect(s!.roster[0].agentId).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout schema', () => {
|
||||
test('validateLayout rejects non-v2 raw', () => {
|
||||
expect(validateLayout(null)).toBeNull();
|
||||
expect(validateLayout({ cells: [] })).toBeNull(); // empty cells, no schema marker → null
|
||||
expect(validateLayout({ cells: [{ roleKey: 'x', deskX: 0, deskY: 0 }] })).toBeNull(); // missing v2 markers
|
||||
});
|
||||
|
||||
test('validateLayout accepts explicit schema:2', () => {
|
||||
const v = validateLayout({
|
||||
schema: 2,
|
||||
cells: [
|
||||
{
|
||||
roleKey: 'ceo',
|
||||
agentKey: 'ceo',
|
||||
label: 'CEO',
|
||||
charRow: 0,
|
||||
deskSprite: 'desk-boss',
|
||||
face: 'R',
|
||||
boss: true,
|
||||
deskX: 100, deskY: 50, deskW: 136,
|
||||
seatX: 130, seatY: 80,
|
||||
deskRot: 0, deskZ: 0, charRot: 0, charZ: 0,
|
||||
noChar: false,
|
||||
},
|
||||
],
|
||||
objs: [{ id: 'obj_0', name: 'plant', x: 10, y: 20, w: 32, rot: 0, z: 0 }],
|
||||
});
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!.cells).toHaveLength(1);
|
||||
expect(v!.cells[0].deskSprite).toBe('desk-boss');
|
||||
expect(v!.objs[0].name).toBe('plant');
|
||||
});
|
||||
|
||||
test('validateLayout normalizes invalid face to R', () => {
|
||||
const v = validateLayout({
|
||||
schema: 2,
|
||||
cells: [{
|
||||
roleKey: 'x', deskSprite: 'desk-main', face: 'XYZ', charRow: 0,
|
||||
deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0,
|
||||
deskRot: 0, deskZ: 0, charRot: 0, charZ: 0,
|
||||
}],
|
||||
});
|
||||
expect(v!.cells[0].face).toBe('R');
|
||||
});
|
||||
|
||||
test('validateLayout detects v2 via field presence (no explicit schema)', () => {
|
||||
const v = validateLayout({
|
||||
cells: [{
|
||||
roleKey: 'a', deskSprite: 'desk-main', charRow: 2,
|
||||
deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0,
|
||||
}],
|
||||
});
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!.cells[0].charRow).toBe(2);
|
||||
});
|
||||
|
||||
test('migrateLayout upgrades v1 (coord-only) cells', () => {
|
||||
const v1 = {
|
||||
cells: [{ roleKey: 'ceo', deskX: 100, deskY: 50, deskW: 136, seatX: 130, seatY: 80, deskRot: 0, deskZ: 0, charRot: 0, charZ: 0 }],
|
||||
objs: [],
|
||||
};
|
||||
const v = migrateLayout(v1);
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!.schema).toBe(2);
|
||||
expect(v!.cells[0].agentKey).toBe('ceo'); // fallback: roleKey == agentKey
|
||||
expect(v!.cells[0].deskSprite).toBe('desk-main');
|
||||
expect(v!.cells[0].charRow).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presenter', () => {
|
||||
test('normalizeAgentId resolves aliases', () => {
|
||||
expect(normalizeAgentId('writer')).toBe('writer');
|
||||
expect(normalizeAgentId('Editor')).toBe('designer');
|
||||
expect(normalizeAgentId('Secretary')).toBe('support');
|
||||
expect(normalizeAgentId('business')).toBe('inspector');
|
||||
expect(normalizeAgentId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('presentOfficeSnapshot builds single-agent roster from old AgentWorkState', () => {
|
||||
const snap = presentOfficeSnapshot({
|
||||
activeState: {
|
||||
agentId: 'inspector',
|
||||
agentName: '감리',
|
||||
status: 'reviewing',
|
||||
currentStep: '라운드 2/3',
|
||||
currentTask: '타입 정합성 검수',
|
||||
recentLogs: ['타입 누락 발견'],
|
||||
updatedAt: 1000,
|
||||
} as any,
|
||||
});
|
||||
expect(snap.phase).toBe('reviewing');
|
||||
expect(snap.activeAgentId).toBe('inspector');
|
||||
expect(snap.roster).toHaveLength(1);
|
||||
expect(snap.roster[0].roleCategory).toBe('inspector');
|
||||
expect(snap.roster[0].lastLog).toBe('타입 누락 발견');
|
||||
expect(snap.task?.goal).toBe('타입 정합성 검수');
|
||||
});
|
||||
|
||||
test('presentOfficeSnapshot returns empty when no input', () => {
|
||||
const snap = presentOfficeSnapshot({});
|
||||
expect(snap.phase).toBe('idle');
|
||||
expect(snap.activeAgentId).toBeNull();
|
||||
expect(snap.roster).toEqual([]);
|
||||
});
|
||||
|
||||
test('presentOfficeSnapshot maps need_clarification to awaiting-approval phase', () => {
|
||||
const snap = presentOfficeSnapshot({
|
||||
activeState: {
|
||||
agentId: 'ceo',
|
||||
agentName: 'CEO',
|
||||
status: 'need_clarification',
|
||||
needUserInput: ['배포 대상 환경은?'],
|
||||
updatedAt: 0,
|
||||
} as any,
|
||||
});
|
||||
expect(snap.phase).toBe('awaiting-approval');
|
||||
expect(snap.awaiting?.kind).toBe('clarification');
|
||||
expect(snap.awaiting?.questions).toEqual(['배포 대상 환경은?']);
|
||||
});
|
||||
|
||||
test('presentOfficeSnapshot with roster places active agent in working state and others idle', () => {
|
||||
const snap = presentOfficeSnapshot({
|
||||
activeState: {
|
||||
agentId: 'developer',
|
||||
agentName: '개발',
|
||||
status: 'executing',
|
||||
currentStep: '함수 추출',
|
||||
recentLogs: ['파일 수정 완료'],
|
||||
updatedAt: 1000,
|
||||
} as any,
|
||||
roster: [
|
||||
{ agentId: 'ceo', agentName: 'CEO', roleCategory: 'ceo' },
|
||||
{ agentId: 'developer', agentName: '개발', roleCategory: 'developer' },
|
||||
{ agentId: 'inspector', agentName: '감리', roleCategory: 'inspector' },
|
||||
],
|
||||
});
|
||||
expect(snap.roster).toHaveLength(3);
|
||||
const active = snap.roster.find((a) => a.agentId === 'developer');
|
||||
const idle = snap.roster.find((a) => a.agentId === 'ceo');
|
||||
expect(active?.status).toBe('executing');
|
||||
expect(active?.lastLog).toBe('파일 수정 완료');
|
||||
expect(idle?.status).toBe('idle');
|
||||
expect(idle?.lastLog).toBeUndefined();
|
||||
});
|
||||
|
||||
test('presentOfficeSnapshot folds recentActivity into snapshot.activity (ring buffer)', () => {
|
||||
const activity = Array.from({ length: 40 }, (_, i) => ({
|
||||
ts: i * 100,
|
||||
agentId: 'developer',
|
||||
text: `step ${i}`,
|
||||
}));
|
||||
const snap = presentOfficeSnapshot({ recentActivity: activity });
|
||||
// presenter 가 32개로 잘라야 함.
|
||||
expect(snap.activity).toHaveLength(32);
|
||||
expect(snap.activity[0].text).toBe('step 8'); // 처음 8개 dropped
|
||||
expect(snap.activity[31].text).toBe('step 39');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* <run_code> 액션 — 코딩 피드백 루프("생성→실행→에러 확인→수정")의 실행 축 검증.
|
||||
* stdout/stderr/종료코드가 internal system 메시지로 재주입되어 자동 후속 턴을
|
||||
* 트리거하는 계약 + 샌드박스(워크스페이스 밖 차단)·확장자 화이트리스트 검증.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { applyRunCodeActions } from '../src/agent/actions/runCode';
|
||||
import { buildSyntaxFixInstruction } from '../src/features/selfReflector/selfReflectorExecution';
|
||||
|
||||
// vscode 는 jest.config.js 의 moduleNameMapper(tests/mocks/vscode.js)가 전역 mock 으로 처리한다.
|
||||
|
||||
let root: string;
|
||||
beforeAll(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-runcode-'));
|
||||
});
|
||||
afterAll(() => {
|
||||
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
});
|
||||
|
||||
function makeCtx(aiMessage: string): any {
|
||||
return { aiMessage, rootPath: root, report: [], chatHistory: [] };
|
||||
}
|
||||
|
||||
describe('applyRunCodeActions', () => {
|
||||
it('정상 실행: stdout 이 exit 0 과 함께 재주입된다 (node)', async () => {
|
||||
fs.writeFileSync(path.join(root, 'ok.js'), "console.log('HELLO_' + (40 + 2));");
|
||||
const ctx = makeCtx('<run_code path="ok.js"/>');
|
||||
await applyRunCodeActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('run_code OK'))).toBe(true);
|
||||
expect(ctx.chatHistory[0].internal).toBe(true);
|
||||
expect(ctx.chatHistory[0].content).toContain('exit 0');
|
||||
expect(ctx.chatHistory[0].content).toContain('HELLO_42');
|
||||
});
|
||||
|
||||
it('실행 에러: stderr + 수정→실행→확인 루프 지시가 재주입된다', async () => {
|
||||
fs.writeFileSync(path.join(root, 'boom.js'), "throw new Error('KABOOM');");
|
||||
const ctx = makeCtx('<run_code path="boom.js"/>');
|
||||
await applyRunCodeActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('FAIL'))).toBe(true);
|
||||
expect(ctx.chatHistory[0].content).toContain('[Result of run_code — ERROR]');
|
||||
expect(ctx.chatHistory[0].content).toContain('KABOOM');
|
||||
expect(ctx.chatHistory[0].content).toContain('<edit_file>');
|
||||
});
|
||||
|
||||
it('워크스페이스 밖 경로는 차단된다', async () => {
|
||||
const ctx = makeCtx('<run_code path="../../etc/hosts.js"/>');
|
||||
await applyRunCodeActions(ctx);
|
||||
expect(ctx.report.some((r: string) => r.includes('blocked'))).toBe(true);
|
||||
expect(ctx.chatHistory).toEqual([]);
|
||||
});
|
||||
|
||||
it('지원하지 않는 확장자는 run_command 안내와 함께 거절된다', async () => {
|
||||
fs.writeFileSync(path.join(root, 'note.md'), '# hi');
|
||||
const ctx = makeCtx('<run_code path="note.md"/>');
|
||||
await applyRunCodeActions(ctx);
|
||||
expect(ctx.chatHistory[0].content).toContain('UNSUPPORTED');
|
||||
expect(ctx.chatHistory[0].content).toContain('run_command');
|
||||
});
|
||||
|
||||
it('존재하지 않는 파일이면 list_files 안내를 재주입한다', async () => {
|
||||
const ctx = makeCtx('<run_code path="ghost.js"/>');
|
||||
await applyRunCodeActions(ctx);
|
||||
expect(ctx.chatHistory[0].content).toContain('존재하지 않습니다');
|
||||
});
|
||||
|
||||
it('한 턴 실행 한도(2회)를 초과하면 건너뛴다', async () => {
|
||||
fs.writeFileSync(path.join(root, 'a.js'), "console.log('a');");
|
||||
const tags = '<run_code path="a.js"/>'.repeat(3);
|
||||
const ctx = makeCtx(tags);
|
||||
await applyRunCodeActions(ctx);
|
||||
const results = ctx.chatHistory.filter((m: any) => m.content.includes('[Result of run_code]'));
|
||||
expect(results.length).toBe(2);
|
||||
expect(ctx.report.some((r: string) => r.includes('한도'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyntaxFixInstruction — 문법 오류 자가수정 루프 메시지', () => {
|
||||
it('실패 라인들과 edit_file 수정 지시를 담는다', () => {
|
||||
const msg = buildSyntaxFixInstruction(['❌ node --check FAIL: a.js — SyntaxError: x']);
|
||||
expect(msg).toContain('SYNTAX ERRORS');
|
||||
expect(msg).toContain('a.js');
|
||||
expect(msg).toContain('<edit_file>');
|
||||
expect(msg).toContain('수동 수정을 요구하지 마라');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user