import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { env } from '../config/env.js'; import { collectOfflineNotificationClientIds, createActivityLogMessage, extractDiffCodeBlocks, fitActivityLogLines, isAutomationRegistrationCountRequest, resolveResponseTimestamp, rewriteCodexOutputWithChatResources, shouldUseAgenticCodexReply, shouldUseTemplateMacroReply, validateAgenticCodexRuntime, } from './chat-service.js'; test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => { assert.deepEqual( collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']), ['client-b', 'client-a', 'client-c'], ); }); test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => { assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true); assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true); assert.equal(isAutomationRegistrationCountRequest('자동화 등록 기준이 뭐야'), false); }); test('shouldUseAgenticCodexReply routes read and modify style requests to real Codex execution', () => { assert.equal(shouldUseAgenticCodexReply('src/app/main/MainChatPanel.tsx 읽어서 구조 설명해줘'), true); assert.equal(shouldUseAgenticCodexReply('DB 직접 조회해서 오늘 오류 건수 확인해줘'), true); assert.equal(shouldUseAgenticCodexReply('MainChatPanel.hotfix.css 수정해줘'), true); }); test('shouldUseAgenticCodexReply keeps fast-path responses for automation registration count questions', () => { assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false); }); test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => { assert.equal( shouldUseTemplateMacroReply( { pageId: null, pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, pageUrl: 'https://test.sm-home.cloud/chat/live', chatTypeLabel: 'API 요청 템플릿', chatTypeDescription: 'API 요청 본문을 정리하는 템플릿', chatTypeIsTemplate: true, }, '이 템플릿 예시 보여줘', ), true, ); assert.equal( shouldUseTemplateMacroReply( { pageId: null, pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, pageUrl: 'https://test.sm-home.cloud/chat/live', chatTypeLabel: 'API 요청 템플릿', chatTypeDescription: 'API 요청 본문을 정리하는 템플릿', chatTypeIsTemplate: true, }, '아이패드 말풍선 폰트 조금 줄여줘', ), false, ); assert.equal( shouldUseTemplateMacroReply( { pageId: null, pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, pageUrl: 'https://test.sm-home.cloud/chat/live', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청', chatTypeIsTemplate: false, }, '템플릿 예시 보여줘', ), false, ); }); test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => { const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`); assert.deepEqual(fitActivityLogLines(lines), lines); }); test('fitActivityLogLines keeps full activity history when it is within the configured limits', () => { const lines = Array.from({ length: 80 }, (_, index) => `# 진행: step ${index + 1}`); const fitted = fitActivityLogLines(lines); assert.equal(fitted.length, 80); assert.equal(fitted[0], '# 진행: step 1'); assert.equal(fitted.at(-1), '# 진행: step 80'); }); test('createActivityLogMessage keeps fitted activity history instead of the latest line only', () => { const lines = ['# 상태: 요청을 처리합니다.', '# 진행: 분석 중입니다.', '# 상태: 응답 생성이 완료되었습니다.']; const message = createActivityLogMessage('req-activity', lines); assert.ok(message); assert.equal( message?.text, '[[activity-log]]\n# 상태: 요청을 처리합니다.\n\n# 진행: 분석 중입니다.\n\n# 상태: 응답 생성이 완료되었습니다.', ); }); test('resolveResponseTimestamp moves fast replies behind the request second', () => { assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01'); }); test('resolveResponseTimestamp keeps the real time when reply is already later', () => { assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 5)), '2026-04-16 09:00:05'); }); test('extractDiffCodeBlocks collects fenced diff bodies', () => { const output = ['설명', '', '```diff', 'diff --git a/a.ts b/a.ts', '+hello', '```', '', '마무리'].join('\n'); assert.deepEqual(extractDiffCodeBlocks(output), ['diff --git a/a.ts b/a.ts\n+hello']); }); test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); await mkdir(path.join(repoPath, 'public'), { recursive: true }); const output = ['변경사항입니다.', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '+hello', '```'].join('\n'); const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); const expectedUrl = '/api/chat/resources/.codex_chat/chat-room/resource/_generated/response.diff'; const savedDiffPath = path.join( repoPath, 'public', '.codex_chat', 'chat-room', 'resource', '_generated', 'response.diff', ); assert.match(rewritten, new RegExp(`\\[\\[preview:${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\]$`, 'm')); assert.doesNotMatch(rewritten, /diff 리소스 경로:/); assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n'); }); test('rewriteCodexOutputWithChatResources keeps diff paths intact while rewriting prose file paths', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const sourcePath = path.join(repoPath, 'src', 'a.ts'); await mkdir(path.dirname(sourcePath), { recursive: true }); await writeFile(sourcePath, 'export const value = 1;\n', 'utf8'); const output = [ '변경 파일: src/a.ts', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts', '@@', '-export const value = 1;', '+export const value = 2;', '```', ].join('\n'); const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); const savedDiffPath = path.join( repoPath, 'public', '.codex_chat', 'chat-room', 'resource', '_generated', 'response.diff', ); assert.match(rewritten, /변경 파일: \/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/a\.ts/); assert.match(rewritten, /diff --git a\/src\/a\.ts b\/src\/a\.ts/); assert.doesNotMatch(rewritten, /diff --git a\/api\/chat\/resources\//); assert.equal( await readFile(savedDiffPath, 'utf8'), [ 'diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts', '@@', '-export const value = 1;', '+export const value = 2;', '', ].join('\n'), ); }); test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx'); const stagedPath = path.join( repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'src', 'app', 'main', 'MainChatPanel.tsx', ); await mkdir(path.dirname(originalPath), { recursive: true }); await mkdir(path.dirname(stagedPath), { recursive: true }); await writeFile(originalPath, 'export const value = 1;\n', 'utf8'); await writeFile(stagedPath, 'export const value = 1;\n', 'utf8'); const output = '리소스 경로는 public/.codex_chat/chat-room/resource/src/app/main/MainChatPanel.tsx 입니다.'; const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/app\/main\/MainChatPanel\.tsx/); assert.doesNotMatch(rewritten, /resource\/public\/\.codex_chat/); assert.equal(await readFile(stagedPath, 'utf8'), 'export const value = 1;\n'); }); test('rewriteCodexOutputWithChatResources stages repo root files linked with a leading slash', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const originalPath = path.join(repoPath, 'docker-compose.yml'); const stagedPath = path.join( repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'docker-compose.yml', ); await mkdir(path.dirname(originalPath), { recursive: true }); await writeFile(originalPath, 'services:\n app:\n image: node:22\n', 'utf8'); const output = '파일은 /docker-compose.yml 에 있습니다.'; const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/docker-compose\.yml/); assert.equal(await readFile(stagedPath, 'utf8'), 'services:\n app:\n image: node:22\n'); }); test('rewriteCodexOutputWithChatResources prefers absolute path replacements before nested relative paths', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const originalPath = path.join(repoPath, 'etc', 'servers', 'work-server', 'package.json'); await mkdir(path.dirname(originalPath), { recursive: true }); await mkdir(path.join(repoPath, 'public'), { recursive: true }); await writeFile(originalPath, '{\n "name": "work-server"\n}\n', 'utf8'); const output = '변경 파일: [/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/package.json](' + `${originalPath})`; const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); assert.match( rewritten, /\[\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\]\(\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\)/, ); assert.doesNotMatch(rewritten, /\/home\/.+\/api\/chat\/resources/); }); test('validateAgenticCodexRuntime explains missing runtime paths clearly', async () => { const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL; const originalFetch = globalThis.fetch; env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health'; globalThis.fetch = (async () => { throw new Error('connect ECONNREFUSED'); }) as typeof globalThis.fetch; try { await assert.rejects( validateAgenticCodexRuntime('/tmp/chat-missing-repo-path', '/tmp/chat-missing-codex-bin'), /채팅 실행 환경이 준비되지 않았습니다\..*PLAN_MAIN_PROJECT_REPO_PATH.*SERVER_COMMAND_RUNNER_URL/s, ); } finally { env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl; globalThis.fetch = originalFetch; } }); test('validateAgenticCodexRuntime accepts reachable command-runner api', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-repo-')); const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL; const originalFetch = globalThis.fetch; env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health'; globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch; try { await assert.doesNotReject(validateAgenticCodexRuntime(repoPath, 'codex')); } finally { env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl; globalThis.fetch = originalFetch; } });