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:
2026-07-05 18:59:47 +09:00
parent fa397d7fa1
commit bb7cd6b879
466 changed files with 1285 additions and 6588 deletions
+59
View File
@@ -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');
});
});
+77
View File
@@ -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([]);
});
});
+81
View File
@@ -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('');
});
});
-241
View File
@@ -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');
});
});
+87
View File
@@ -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('수동 수정을 요구하지 마라');
});
});