import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { env } from '../config/env.js';
import {
  ChatService,
  collectOfflineNotificationClientIds,
  createActivityLogMessage,
  buildAgenticCodexPrompt,
  ensureChatSessionResourceDirectories,
  ensureChatSessionReferenceResource,
  extractDiffCodeBlocks,
  extractCodexStreamText,
  parseStructuredCodexStdoutLine,
  filterInactiveOfflineNotificationClientIds,
  fitActivityLogLines,
  isChatClientActivelyViewing,
  isAutomationRegistrationCountRequest,
  buildParticipantRequestInput,
  resolveCodexExecutionStages,
  resolveCodexParticipantsForExecution,
  resolveResponseTimestamp,
  resolveChatContextAppOrigin,
  resolveChatContextAppDomain,
  rewriteCodexOutputWithChatResources,
  sanitizeChatContextOverride,
  summarizeActivityProgressLine,
  shouldSendOfflineChatNotification,
  shouldUseAgenticCodexReply,
  shouldUseTemplateMacroReply,
  validateAgenticCodexRuntime,
} from './chat-service.js';
import { extractChatMessageParts } from './chat-message-parts.js';

test('ChatService rebinds a websocket to the payload session before handling a send request', () => {
  const logger = {
    error() {},
    warn() {},
    info() {},
  } as any;
  const service = new ChatService(logger);
  let supersededSocketClosed = false;

  try {
    const currentSocket = {
      readyState: 1,
      close() {},
    } as any;
    const supersededTargetSocket = {
      readyState: 1,
      close() {
        supersededSocketClosed = true;
      },
    } as any;
    const currentSession = {
      sessionId: 'session-a',
      clientId: 'client-1',
      socket: currentSocket,
      lastSeenAt: 0,
      isDeleted: false,
      context: null,
      queue: [],
      activeRequestCount: 0,
      pendingQueueReleaseEventId: null,
      nextEventId: 1,
      eventHistory: [],
      messagePersistenceTail: Promise.resolve(),
      watchedRuntimeRequestId: null,
    };
    const targetSession = {
      sessionId: 'session-b',
      clientId: 'client-1',
      socket: supersededTargetSocket,
      lastSeenAt: 0,
      isDeleted: false,
      context: null,
      queue: [],
      activeRequestCount: 0,
      pendingQueueReleaseEventId: null,
      nextEventId: 1,
      eventHistory: [],
      messagePersistenceTail: Promise.resolve(),
      watchedRuntimeRequestId: null,
    };

    (service as any).sessions.set(currentSession.sessionId, currentSession);
    (service as any).sessions.set(targetSession.sessionId, targetSession);
    (service as any).clientStates.set(currentSocket, currentSession);

    const rebound = (service as any).rebindSocketToSession(currentSocket, 'session-b');

    assert.equal(rebound, targetSession);
    assert.equal(currentSession.socket, null);
    assert.equal(targetSession.socket, currentSocket);
    assert.equal((service as any).clientStates.get(currentSocket), targetSession);
    assert.equal(supersededSocketClosed, true);
  } finally {
    service.close();
  }
});

test('collectOfflineNotificationClientIds keeps only explicit notification targets without duplicates', () => {
  assert.deepEqual(
    collectOfflineNotificationClientIds(['client-b', ' client-a ', '', 'client-c', 'client-b']),
    ['client-b', 'client-a', 'client-c'],
  );
});

test('filterInactiveOfflineNotificationClientIds excludes only actively viewing clients', () => {
  const activeSession = {
    sessionId: 'chat-room',
    clientId: 'client-a',
    socket: { readyState: 1 },
    lastSeenAt: Date.now(),
    context: {
      pageVisibilityState: 'visible' as const,
      pageFocusState: 'focused' as const,
      topMenu: 'chat',
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
    },
  };

  assert.deepEqual(
    filterInactiveOfflineNotificationClientIds(['client-a', 'client-b', 'client-c'], [activeSession] as any),
    ['client-b', 'client-c'],
  );
});

test('sanitizeChatContextOverride drops undefined codexModel without touching explicit null', () => {
  assert.deepEqual(
    sanitizeChatContextOverride({
      codexModel: undefined,
      chatTypeId: 'general-request',
    } as any),
    {
      chatTypeId: 'general-request',
    },
  );

  assert.deepEqual(
    sanitizeChatContextOverride({
      codexModel: null,
      chatTypeId: 'general-request',
    }),
    {
      codexModel: null,
      chatTypeId: 'general-request',
    },
  );
});

test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => {
  assert.equal(
    shouldSendOfflineChatNotification({
      receiveRoomNotifications: false,
      conversationNotifyOffline: true,
    }),
    false,
  );

  assert.equal(
    shouldSendOfflineChatNotification({
      receiveRoomNotifications: true,
      conversationNotifyOffline: false,
    }),
    false,
  );

  assert.equal(
    shouldSendOfflineChatNotification({
      receiveRoomNotifications: true,
      conversationNotifyOffline: true,
    }),
    true,
  );
});

test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
  assert.equal(
    resolveChatContextAppOrigin({
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
    } as any),
    'https://preview.sm-home.cloud',
  );
  assert.equal(resolveChatContextAppOrigin({ pageUrl: 'not-a-url' } as any), null);
  assert.equal(resolveChatContextAppOrigin(null), null);
});

test('resolveChatContextAppOrigin prefers explicit app origin metadata when page url is missing', () => {
  assert.equal(
    resolveChatContextAppOrigin({
      pageUrl: '',
      appOrigin: 'https://test.sm-home.cloud',
    } as any),
    'https://test.sm-home.cloud',
  );
});

test('resolveChatContextAppDomain returns normalized hostname from session page url', () => {
  assert.equal(
    resolveChatContextAppDomain({
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
    } as any),
    'preview.sm-home.cloud',
  );
  assert.equal(resolveChatContextAppDomain({ pageUrl: 'not-a-url' } as any), null);
  assert.equal(resolveChatContextAppDomain(null), null);
});

test('resolveChatContextAppDomain prefers explicit app domain metadata when page url is missing', () => {
  assert.equal(
    resolveChatContextAppDomain({
      pageUrl: '',
      appDomain: 'TEST.SM-HOME.CLOUD',
    } as any),
    'test.sm-home.cloud',
  );
});

test('chat active-view suppression only blocks the requester client when that client app is active', () => {
  const activeSession = {
    sessionId: 'chat-room',
    clientId: 'client-a',
    socket: { readyState: 1 },
    lastSeenAt: Date.now(),
    context: {
      pageVisibilityState: 'visible' as const,
      pageFocusState: 'focused' as const,
      topMenu: 'chat',
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
    },
  };

  assert.equal(isChatClientActivelyViewing('client-a', [activeSession] as any), true);
  assert.equal(
    isChatClientActivelyViewing('client-a', [
      {
        ...activeSession,
        context: { ...activeSession.context, pageFocusState: 'blurred' },
      },
    ] as any),
    false,
  );
  assert.equal(
    isChatClientActivelyViewing('client-a', [
      {
        ...activeSession,
        context: { ...activeSession.context, pageVisibilityState: 'hidden' },
      },
    ] as any),
    false,
  );
  assert.equal(
    isChatClientActivelyViewing('client-a', [
      {
        ...activeSession,
        context: {
          ...activeSession.context,
          pageUrl: 'https://external.example.com/chat/live?sessionId=chat-room',
        },
      },
    ] as any),
    false,
  );
  assert.equal(
    isChatClientActivelyViewing('client-a', [
      {
        ...activeSession,
        clientId: 'client-b',
      },
    ] as any),
    false,
  );
  assert.equal(
    isChatClientActivelyViewing('client-a', [
      {
        ...activeSession,
        clientId: 'client-b',
      },
      activeSession,
    ] as any),
    true,
  );
});

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 is disabled for chat types', () => {
  assert.equal(
    shouldUseTemplateMacroReply(
      {
        pageId: null,
        pageTitle: 'Codex Live',
        topMenu: 'chat',
        focusedComponentId: null,
        pageUrl: 'https://preview.sm-home.cloud/chat/live',
        chatTypeLabel: 'API요청',
        chatTypeDescription: 'API 요청 본문을 정리합니다.',
      },
      '이 템플릿 예시 보여줘',
    ),
    false,
  );

  assert.equal(
    shouldUseTemplateMacroReply(
      {
        pageId: null,
        pageTitle: 'Codex Live',
        topMenu: 'chat',
        focusedComponentId: null,
        pageUrl: 'https://preview.sm-home.cloud/chat/live',
        chatTypeLabel: 'API요청',
        chatTypeDescription: 'API 요청 본문을 정리합니다.',
      },
      '아이패드 말풍선 폰트 조금 줄여줘',
    ),
    false,
  );

  assert.equal(
    shouldUseTemplateMacroReply(
      {
        pageId: null,
        pageTitle: 'Codex Live',
        topMenu: 'chat',
        focusedComponentId: null,
        pageUrl: 'https://preview.sm-home.cloud/chat/live',
        chatTypeLabel: '일반 요청',
        chatTypeDescription: '일반 요청',
      },
      '템플릿 예시 보여줘',
    ),
    false,
  );
});

test('buildAgenticCodexPrompt treats chat type context as required instructions', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '모바일 검증',
      chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.',
    },
    '화면 확인해줘',
    'session-a',
    {
      recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'],
      omittedHistoryCount: 2,
      sessionReferenceContent: [
        '# 채팅방 참고 리소스',
        '',
        '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
        '',
        '## 수동 메모',
        '- 신규 방이어도 시작 전에 이 문서를 먼저 읽습니다.',
      ].join('\n'),
    },
  );

  assert.match(prompt, /## 채팅방 참고 문서/);
  assert.match(prompt, /이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다\./);
  assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./);
  assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
  assert.match(prompt, /상위 필수 지시/);
  assert.match(
    prompt,
    /우선순위는 1\. 채팅 유형 context 2\. 현재 턴의 직접 사용자 지시 3\. 채팅방에서 선택한 공통 문맥과 전용 메모 4\. 최근 대화 문맥과 화면 문맥 순서로 해석하세요\./,
  );
  assert.match(prompt, /사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
  assert.match(prompt, /공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며/);
  assert.match(prompt, /### 반드시 지킬 context 원문/);
  assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
  assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
  assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
  assert.match(
    prompt,
    /리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면\/메뉴 기준으로 판단하세요\./,
  );
  assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
  assert.match(prompt, /`steps` 배열을 추가해/);
  assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
  assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
  assert.match(
    prompt,
    /`preview":\{"type":"resource","url":"resource\/<수정한 화면명>\/<기능>\/<YYYYMMDD>\/sample\.html"\}`/,
  );
  assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
  assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
  assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
  assert.ok(prompt.indexOf('## 채팅방 참고 문서') < prompt.indexOf('## 채팅 유형 context 필수 규칙'));
  assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});

test('buildAgenticCodexPrompt uses the resolved main project root instead of an env example path', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    'AGENTS 먼저 읽고 확인해줘',
    'session-path',
    {
      repoPath: '/home/how2ice/project/ai-code-app',
    },
  );

  assert.match(prompt, /저장소 루트\(main_project\): \/home\/how2ice\/project\/ai-code-app/);
  assert.doesNotMatch(prompt, /저장소 루트\(main_project\): \/workspace\/main-project/);
});

test('buildAgenticCodexPrompt keeps running when session reference content is unavailable', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    '바로 확인해줘',
    'session-no-reference',
    {
      sessionReferenceResourcePath: 'public/.codex_chat/session-no-reference/resource/source/chat-room-reference.md',
      sessionReferenceContent: '',
    },
  );

  assert.match(prompt, /## 채팅방 참고 문서/);
  assert.match(prompt, /현재 채팅방 참고 문서 본문을 불러오지 못했습니다\./);
  assert.match(prompt, /위에 제공된 참고 문서 경로를 우선 확인하고/);
});

test('buildAgenticCodexPrompt keeps the chat type label provided by the client context', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    '바로 코드 수정 기준으로 다음 단계를 이어서 진행해 주세요.',
    'session-general',
  );

  assert.match(prompt, /- label: 일반 요청/);
  assert.doesNotMatch(prompt, /- label: 코드 수정/);
});

test('buildAgenticCodexPrompt pins the explicitly referenced answer ahead of recent history', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    '지금 무슨 답변을 참조했나요?',
    'session-reference',
    {
      recentHistoryLines: [
        '[user] 예전 질문',
        '[codex] 다른 답변',
      ],
      referencedRequest: {
        sessionId: 'session-reference',
        requestId: 'request-123',
        requesterClientId: null,
        chatTypeId: 'general-request',
        chatTypeLabel: '일반 요청',
        requestOrigin: 'composer',
        sharedResourceTokenId: null,
        parentRequestId: null,
        promptContextRef: null,
        status: 'completed',
        statusMessage: '요청 처리 완료',
        retryCount: 0,
        userMessageId: 1,
        userText: 'preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼?',
        responseMessageId: 2,
        responseText: '배포 누락이 아니라 iframe 차단입니다.',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: true,
        canDelete: false,
        manualPromptCompletedAt: null,
        manualVerificationCompletedAt: null,
        createdAt: '2026-05-27T14:50:23.000Z',
        updatedAt: '2026-05-27T14:51:00.000Z',
        answeredAt: '2026-05-27T14:51:00.000Z',
        terminalAt: '2026-05-27T14:51:00.000Z',
      },
    },
  );

  assert.match(prompt, /## 답변 참조/);
  assert.match(prompt, /참조 requestId: request-123/);
  assert.match(prompt, /참조 사용자 요청: preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼\?/);
  assert.match(prompt, /참조 답변 본문: 배포 누락이 아니라 iframe 차단입니다\./);
  assert.match(prompt, /다른 최근 답변을 임의로 섞지 마세요\./);
  assert.ok(prompt.indexOf('## 답변 참조') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});

test('resolveCodexParticipantsForExecution expands moderator into opening and closing turns', () => {
  const participants = resolveCodexParticipantsForExecution({
    pageId: null,
    pageTitle: 'Codex Live',
    topMenu: 'chat',
    focusedComponentId: null,
    pageUrl: 'https://preview.sm-home.cloud/chat/live',
    codexParticipants: [
      {
        id: 'codex-1',
        name: 'Codex 1',
        model: 'gpt-5.4',
        role: 'moderator',
      },
      {
        id: 'codex-2',
        name: 'Codex 2',
        model: 'gpt-5.4',
        role: 'conversation',
      },
      {
        id: 'codex-3',
        name: 'Codex 3',
        model: 'gpt-5.4',
        role: 'conversation',
      },
    ],
  } as any);

  assert.deepEqual(
    participants.map((participant) => `${participant.name}:${participant.turn}`),
    ['Codex 1:opening', 'Codex 2:discussion', 'Codex 3:discussion', 'Codex 1:closing'],
  );
});

test('resolveCodexParticipantsForExecution applies dispatcher policy with reviewer slot', () => {
  const participants = resolveCodexParticipantsForExecution({
    pageId: null,
    pageTitle: 'Codex Live',
    topMenu: 'chat',
    focusedComponentId: null,
    pageUrl: 'https://preview.sm-home.cloud/chat/live',
    chatTypeExecutionPolicy: {
      mode: 'dispatcher-workers',
      participantBinding: 'first-moderator-rest-conversation-last-reviewer',
      reviewPolicy: 'reviewer',
      resourceReportPolicy: 'if-generated',
      allowModeratorIntervention: true,
      finalSummaryRequired: true,
    },
    codexParticipants: [
      {
        id: 'codex-1',
        name: 'Codex 1',
        model: 'gpt-5.4',
        role: 'default',
      },
      {
        id: 'codex-2',
        name: 'Codex 2',
        model: 'gpt-5.4',
        role: 'default',
      },
      {
        id: 'codex-3',
        name: 'Codex 3',
        model: 'gpt-5.4',
        role: 'default',
      },
    ],
  } as any);

  assert.deepEqual(
    participants.map((participant) => `${participant.name}:${participant.turn}:${participant.role}`),
    [
      'Codex 1:opening:moderator',
      'Codex 2:discussion:conversation',
      'Codex 3:review:reviewer',
      'Codex 1:closing:moderator',
    ],
  );
});

test('resolveCodexExecutionStages runs direct multi-Codex default requests in parallel without closing summary', () => {
  const stages = resolveCodexExecutionStages(
    {
      codexModel: 'gpt-5.4',
      codexParticipants: [
        { name: 'Codex 1', model: 'gpt-5.4', role: 'moderator' },
        { name: 'Codex 2', model: 'gpt-5.4-mini', role: 'conversation' },
        { name: 'Codex 3', model: 'gpt-5.4', role: 'default' },
      ],
      chatTypeExecutionPolicy: {
        mode: 'default',
        participantBinding: 'manual',
        reviewPolicy: 'self',
        resourceReportPolicy: 'if-generated',
        allowModeratorIntervention: true,
        finalSummaryRequired: false,
      },
    } as any,
    'direct',
  );

  assert.equal(stages.length, 1);
  assert.equal(stages[0]?.parallel, true);
  assert.deepEqual(
    stages[0]?.participants.map((participant) => `${participant.name}:${participant.turn}`),
    ['Codex 1:standard', 'Codex 2:discussion', 'Codex 3:standard'],
  );
});

test('resolveCodexExecutionStages keeps moderator summary flow while parallelizing discussion stage', () => {
  const stages = resolveCodexExecutionStages(
    {
      codexModel: 'gpt-5.4',
      codexParticipants: [
        { name: '회의기록자', model: 'gpt-5.4', role: 'moderator' },
        { name: '구현자 A', model: 'gpt-5.4-mini', role: 'conversation' },
        { name: '구현자 B', model: 'gpt-5.4', role: 'conversation' },
      ],
      chatTypeExecutionPolicy: {
        mode: 'summary-free-talking',
        participantBinding: 'manual',
        reviewPolicy: 'self',
        resourceReportPolicy: 'if-generated',
        allowModeratorIntervention: false,
        finalSummaryRequired: true,
      },
    } as any,
    'direct',
  );

  assert.deepEqual(
    stages.map((stage) => ({
      parallel: stage.parallel,
      participants: stage.participants.map((participant) => `${participant.name}:${participant.turn}`),
    })),
    [
      { parallel: false, participants: ['회의기록자:opening'] },
      { parallel: true, participants: ['구현자 A:discussion', '구현자 B:discussion'] },
      { parallel: false, participants: ['회의기록자:closing'] },
    ],
  );
});

test('buildParticipantRequestInput gives moderator and discussion participants distinct instructions', () => {
  const participants = [
    {
      name: 'Codex 1',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'moderator' as const,
      turn: 'opening' as const,
    },
    {
      name: 'Codex 2',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'conversation' as const,
      turn: 'discussion' as const,
    },
    {
      name: 'Codex 1',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'moderator' as const,
      turn: 'closing' as const,
    },
  ];
  const openingInput = buildParticipantRequestInput('요청 본문', participants[0], participants, []);
  const closingInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [
    {
      name: 'Codex 1',
      model: 'gpt-5.4',
      text: '쟁점을 정리합니다.',
    },
    {
      name: 'Codex 2',
      model: 'gpt-5.4',
      text: '구현 관점 보완입니다.',
    },
  ]);

  assert.match(openingInput, /회의 기록자 겸 중재자/);
  assert.match(openingInput, /실행 정책: default/);
  assert.match(openingInput, /Codex 1\(gpt-5\.4, 중재 시작\) -> Codex 2\(gpt-5\.4, 프리토킹\) -> Codex 1\(gpt-5\.4, 최종 정리\)/);
  assert.match(closingInput, /최종 결론과 남은 쟁점을 정리/);
  assert.match(closingInput, /이전 Codex 발언/);
});

test('buildParticipantRequestInput uses dispatcher and reviewer instructions from execution policy', () => {
  const participants = [
    {
      name: 'Codex 1',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'moderator' as const,
      turn: 'opening' as const,
    },
    {
      name: 'Codex 2',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'conversation' as const,
      turn: 'discussion' as const,
    },
    {
      name: 'Codex 3',
      model: 'gpt-5.4',
      prompt: '',
      chatTypeId: null,
      role: 'reviewer' as const,
      turn: 'review' as const,
    },
  ];

  const dispatcherInput = buildParticipantRequestInput('요청 본문', participants[0], participants, [], null, {
    mode: 'dispatcher-workers',
    participantBinding: 'first-moderator-rest-conversation-last-reviewer',
    reviewPolicy: 'reviewer',
    resourceReportPolicy: 'always',
    allowModeratorIntervention: true,
    finalSummaryRequired: true,
  });
  const reviewerInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [], null, {
    mode: 'dispatcher-workers',
    participantBinding: 'first-moderator-rest-conversation-last-reviewer',
    reviewPolicy: 'reviewer',
    resourceReportPolicy: 'always',
    allowModeratorIntervention: true,
    finalSummaryRequired: true,
  });

  assert.match(dispatcherInput, /중계 지시자/);
  assert.match(dispatcherInput, /결과물 보고 정책: always/);
  assert.match(reviewerInput, /최종 검토자/);
  assert.match(reviewerInput, /최종 종합 강제: 예/);
});

test('buildParticipantRequestInput keeps prompt parent question context in a separate server block', () => {
  const participant = {
    name: 'Codex 1',
    model: 'gpt-5.4',
    prompt: '',
    chatTypeId: null,
    role: 'moderator' as const,
    turn: 'opening' as const,
  };

  const input = buildParticipantRequestInput(
    '실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요.\n\n추가 요청:\n테스트',
    participant,
    [participant],
    [],
    null,
    null,
    {
      key: 'prompt_parent_question',
      promptTitle: '다음 확인 선택',
      promptDescription: '이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다.',
      parentQuestionText: 'prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요(질의 응답 부모 명확하게 전달)',
    },
  );

  assert.match(input, /^실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요\./);
  assert.match(input, /prompt 문맥 참조:/);
  assert.match(input, /상위 사용자 질의: prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요/);
  assert.match(input, /대상 질의: 다음 확인 선택/);
  assert.match(input, /질의 설명: 이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다\./);
});

test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => {
  const prompt = buildAgenticCodexPrompt(
    {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '합성된 설명',
      chatTypeBaseDescription: '## 기본 처리\n- 채팅 유형 원문 규칙',
      defaultContexts: [
        {
          id: 'ctx-a',
          title: '권한 관리 공통 문맥',
          content: '## 권한 규칙\n- 채팅방에서 선택된 공통 문맥도 항상 반영합니다.',
        },
        {
          id: 'ctx-b',
          title: '방 전용 공통 문맥',
          content: '- 신규 방에서도 같은 규칙으로 동작해야 합니다.',
        },
      ],
      customContextTitle: '운영 메모',
      customContextContent: '- preview 기준으로 검증합니다.',
    },
    '실제 반영 상태를 확인해줘',
    'session-structured',
  );

  assert.match(prompt, /## 채팅 유형 context 원문/);
  assert.match(prompt, /채팅 유형 원문 규칙/);
  assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/);
  assert.match(prompt, /채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요\./);
  assert.match(prompt, /### 권한 관리 공통 문맥/);
  assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./);
  assert.match(prompt, /### 방 전용 공통 문맥/);
  assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./);
  assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/);
  assert.match(prompt, /채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요\./);
  assert.match(prompt, /preview 기준으로 검증합니다\./);
});

test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));

  const resourcePath = await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-1',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    input: '코드 수정 요청',
    recentHistoryLines: ['[user] 이전 요청'],
    omittedHistoryCount: 0,
  });

  assert.equal(resourcePath, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');

  const absolutePath = path.join(tempDir, resourcePath);
  const firstContent = await readFile(absolutePath, 'utf8');
  assert.match(firstContent, /# 채팅방 참고 리소스/);
  assert.match(firstContent, /## 자동 갱신 문맥/);
  assert.match(firstContent, /- 요청 상태: 실행 중/);
  assert.match(firstContent, /- 요청 시작 시각: /);
  assert.match(firstContent, /- 채팅 유형: 일반 요청/);
  assert.doesNotMatch(firstContent, /- 요청 종료 시각: /);
  assert.doesNotMatch(firstContent, /## 수동 메모/);
  assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
  assert.doesNotMatch(firstContent, /## 최근 대화 요약/);

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-2',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: 'message-list',
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    requestStatus: 'completed',
    requestedAt: new Date('2026-05-14T06:00:00.000Z'),
    completedAt: new Date('2026-05-14T06:00:05.000Z'),
    input: '둘째 요청',
    recentHistoryLines: ['[user] 첫 요청', '[codex] 첫 답변'],
    omittedHistoryCount: 1,
  });

  const updatedContent = await readFile(absolutePath, 'utf8');
  assert.match(updatedContent, /request-2/);
  assert.match(updatedContent, /- 요청 상태: 완료/);
  assert.match(updatedContent, /- 요청 종료 시각: /);
  assert.doesNotMatch(updatedContent, /둘째 요청/);
  assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
});

test('ensureChatSessionReferenceResource rewrites the auto section with terminal timing metadata', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-terminal-'));

  const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-started',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '기본처리 설명',
    },
    requestStatus: 'started',
    requestedAt: new Date('2026-05-14T06:00:00.000Z'),
    input: '자동갱신 시작 시점 확인',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-finished',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '기본처리 설명',
    },
    requestStatus: 'failed',
    requestedAt: new Date('2026-05-14T06:00:00.000Z'),
    completedAt: new Date('2026-05-14T06:00:05.000Z'),
    input: '자동갱신 종료 시점 확인',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  const content = await readFile(absolutePath, 'utf8');
  assert.match(content, /request-finished/);
  assert.match(content, /- 요청 상태: 실패/);
  assert.match(content, /- 요청 시작 시각: /);
  assert.match(content, /- 요청 종료 시각: /);
  assert.doesNotMatch(content, /request-started/);
});

test('ensureChatSessionReferenceResource updates the same request to completed status', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-same-request-'));
  const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
  const requestedAt = new Date('2026-05-14T06:00:00.000Z');
  const completedAt = new Date('2026-05-14T06:05:00.000Z');

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-same',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '기본처리 설명',
    },
    requestStatus: 'started',
    requestedAt,
    input: '같은 요청 시작',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-same',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '기본처리 설명',
    },
    requestStatus: 'completed',
    requestedAt,
    completedAt,
    input: '같은 요청 완료',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  const content = await readFile(absolutePath, 'utf8');
  assert.match(content, /request-same/);
  assert.match(content, /- 요청 상태: 완료/);
  assert.match(content, /- 요청 시작 시각: 2026-05-14 15:00:00/);
  assert.match(content, /- 요청 종료 시각: 2026-05-14 15:05:00/);
});

test('ensureChatSessionReferenceResource summarizes default contexts without copying full shared rules', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-summary-'));

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-summary',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '합성된 설명',
      chatTypeBaseDescription: '## 기본 처리\n- Plan 체크리스트 기준',
      defaultContexts: [
        {
          id: 'ctx-resource',
          title: '개발 리소스 관리',
          content: '## 리소스 관리 등록 기준\n- 리소스 관리 상세 규칙 본문',
        },
        {
          id: 'ctx-output',
          title: '리소스 출력',
          content: '## 리소스 출력\n- resource 경로 우선 규칙',
        },
      ],
      customContextTitle: '운영 메모',
      customContextContent: '- 이 방에서는 preview 기준 검증만 유지합니다.',
    },
    input: '공통 문맥 이관 이후 참고 문서 정리',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
  const content = await readFile(absolutePath, 'utf8');

  assert.match(content, /## 현재 채팅 유형 context 요약/);
  assert.match(content, /채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다\./);
  assert.match(content, /### 적용 중인 공통 문맥/);
  assert.match(content, /- 개발 리소스 관리/);
  assert.match(content, /- 리소스 출력/);
  assert.match(content, /### 채팅방 전용 Context · 운영 메모/);
  assert.match(content, /preview 기준 검증만 유지합니다\./);
  assert.doesNotMatch(content, /## 채팅방에서 선택한 공통 문맥/);
  assert.doesNotMatch(content, /리소스 관리 상세 규칙 본문/);
  assert.doesNotMatch(content, /resource 경로 우선 규칙/);
});

test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-session-dirs-'));
  const resourceRoot = path.join(tempDir, 'public/.codex_chat/chat-room/resource');

  await mkdir(resourceRoot, { recursive: true });

  const ensured = await ensureChatSessionResourceDirectories(tempDir, 'chat-room');
  const uploadStat = await stat(ensured.uploadRoot);
  const sourceStat = await stat(ensured.sourceRoot);

  assert.equal(uploadStat.isDirectory(), true);
  assert.equal(sourceStat.isDirectory(), true);
  assert.equal(uploadStat.mode & 0o777, 0o777);
  assert.equal(sourceStat.mode & 0o777, 0o777);
});

test('ensureChatSessionReferenceResource rebuilds a corrupted auto section without keeping duplicated history text', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-corrupted-'));
  const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
  await mkdir(path.dirname(absolutePath), { recursive: true });
  await writeFile(
    absolutePath,
    [
      '# 채팅방 참고 리소스',
      '',
      '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
      '',
      '<!-- codex-live:auto:start -->',
      '## 자동 갱신 문맥',
      '- 오래된 본문',
      '<!-- codex-live:auto:end -->',
      "이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
      '<!-- codex-live:auto:end -->',
      '',
    ].join('\n'),
    'utf8',
  );

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-3',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '일반 요청',
      chatTypeDescription: '일반 요청 설명',
    },
    input: '셋째 요청',
    recentHistoryLines: ['[user] 둘째 요청'],
    omittedHistoryCount: 0,
  });

  const rebuiltContent = await readFile(absolutePath, 'utf8');
  assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
  assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
  assert.doesNotMatch(rebuiltContent, /셋째 요청/);
  assert.doesNotMatch(rebuiltContent, /## 수동 메모/);
  assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
});

test('ensureChatSessionReferenceResource recreates the file when the existing reference is read-only', async () => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-readonly-'));
  const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
  await mkdir(path.dirname(absolutePath), { recursive: true, mode: 0o777 });
  await writeFile(
    absolutePath,
    [
      '# 채팅방 참고 리소스',
      '',
      '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
      '',
      '<!-- codex-live:auto:start -->',
      '## 자동 갱신 문맥',
      '- requestId: stale-request',
      '- 오래된 자동 갱신 본문',
      '<!-- codex-live:auto:end -->',
      '',
    ].join('\n'),
    'utf8',
  );
  await chmod(path.dirname(absolutePath), 0o777);
  await chmod(absolutePath, 0o444);

  await ensureChatSessionReferenceResource({
    repoPath: tempDir,
    sessionId: 'chat-room',
    requestId: 'request-readonly',
    context: {
      pageId: null,
      pageTitle: 'Codex Live',
      topMenu: 'chat',
      focusedComponentId: null,
      pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
      chatTypeLabel: '기본처리',
      chatTypeDescription: '기본처리 설명',
    },
    input: '자동 갱신 권한 복구 확인',
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });

  const rebuiltContent = await readFile(absolutePath, 'utf8');
  assert.match(rebuiltContent, /request-readonly/);
  assert.match(rebuiltContent, /## 자동 갱신 문맥/);
  assert.doesNotMatch(rebuiltContent, /stale-request/);
  assert.doesNotMatch(rebuiltContent, /오래된 자동 갱신 본문/);
});

test('extractChatMessageParts strips link-card markers into structured parts', () => {
  assert.deepEqual(
    extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://preview.sm-home.cloud/chat/live|열기]]'].join('\n')),
    {
      strippedText: '결과 본문',
      parts: [
        {
          type: 'link_card',
          title: '미리보기',
          url: 'https://preview.sm-home.cloud/chat/live',
          actionLabel: '열기',
        },
      ],
    },
  );
});

test('extractChatMessageParts strips prompt markers into structured parts', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '단계형 선택지를 준비했습니다.',
        '[[prompt:{"title":"다음 단계 선택","description":"원하는 작업 흐름을 고르세요.","submitLabel":"계속","mode":"queue","options":[{"label":"요약 먼저","value":"summary-first","description":"현황 요약 후 구현합니다."},{"label":"바로 구현","value":"implement-now","description":"확인 없이 바로 수정합니다."}],"responseTemplate":"사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요."}]]',
      ].join('\n'),
    ),
    {
      strippedText: '단계형 선택지를 준비했습니다.',
      parts: [
        {
          type: 'prompt',
          title: '다음 단계 선택',
          description: '원하는 작업 흐름을 고르세요.',
          submitLabel: '계속',
          mode: 'queue',
          multiple: false,
          responseTemplate: '사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요.',
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: '요약 먼저',
              value: 'summary-first',
              description: '현황 요약 후 구현합니다.',
              preview: null,
            },
            {
              label: '바로 구현',
              value: 'implement-now',
              description: '확인 없이 바로 수정합니다.',
              preview: null,
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts keeps readonly auto-selected prompt state', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '시안 3개 중 자동 선택 결과입니다.',
        '[[prompt:{"title":"UI 시안 선택","description":"시간 안에 응답이 없어 자동 선택되었습니다.","readOnly":true,"selectedValues":["option-b"],"resolvedBy":"timeout","resultText":"B안이 기본 시안으로 채택되었습니다.","options":[{"label":"A안","value":"option-a","description":"카드 레이아웃 중심"},{"label":"B안","value":"option-b","description":"탭과 요약 헤더 중심"},{"label":"C안","value":"option-c","description":"하단 플로팅 액션 중심"}]}]]',
      ].join('\n'),
    ),
    {
      strippedText: '시안 3개 중 자동 선택 결과입니다.',
      parts: [
        {
          type: 'prompt',
          title: 'UI 시안 선택',
          description: '시간 안에 응답이 없어 자동 선택되었습니다.',
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: true,
          selectedValues: ['option-b'],
          resolvedBy: 'timeout',
          resolvedAt: null,
          resultText: 'B안이 기본 시안으로 채택되었습니다.',
          steps: undefined,
          options: [
            {
              label: 'A안',
              value: 'option-a',
              description: '카드 레이아웃 중심',
              preview: null,
            },
            {
              label: 'B안',
              value: 'option-b',
              description: '탭과 요약 헤더 중심',
              preview: null,
            },
            {
              label: 'C안',
              value: 'option-c',
              description: '하단 플로팅 액션 중심',
              preview: null,
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts keeps prompt writable when only selectedValues exist', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
        '[[prompt:{"title":"후속 범위 선택","description":"미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.","selectedValues":["mobile-cleanup"],"options":[{"label":"모바일 정리","value":"mobile-cleanup","description":"모바일 여백 정리"},{"label":"데스크톱 정리","value":"desktop-cleanup","description":"데스크톱 여백 정리"}]}]]',
      ].join('\n'),
    ),
    {
      strippedText: '이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
      parts: [
        {
          type: 'prompt',
          title: '후속 범위 선택',
          description: '미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.',
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: ['mobile-cleanup'],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: '모바일 정리',
              value: 'mobile-cleanup',
              description: '모바일 여백 정리',
              preview: null,
            },
            {
              label: '데스크톱 정리',
              value: 'desktop-cleanup',
              description: '데스크톱 여백 정리',
              preview: null,
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '시안 미리보기 선택입니다.',
        '[[prompt:{"title":"시안 선택","options":[{"label":"이미지안","value":"image-a","preview":{"type":"image","url":"https://example.com/a.png","alt":"A"}},{"label":"마크다운안","value":"markdown-b","preview":{"type":"markdown","content":"## B안\\n- 설명"}},{"label":"HTML안","value":"html-c","preview":{"type":"html","content":"<section>C</section>","title":"HTML C"}},{"label":"리소스안","value":"resource-d","preview":{"type":"resource","url":"/api/chat/resources/sample.html","title":"리소스"}}]}]]',
      ].join('\n'),
    ),
    {
      strippedText: '시안 미리보기 선택입니다.',
      parts: [
        {
          type: 'prompt',
          title: '시안 선택',
          description: null,
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: '이미지안',
              value: 'image-a',
              description: null,
              preview: {
                type: 'image',
                url: 'https://example.com/a.png',
                content: null,
                alt: 'A',
                title: null,
              },
            },
            {
              label: '마크다운안',
              value: 'markdown-b',
              description: null,
              preview: {
                type: 'markdown',
                url: null,
                content: '## B안\n- 설명',
                alt: null,
                title: null,
              },
            },
            {
              label: 'HTML안',
              value: 'html-c',
              description: null,
              preview: {
                type: 'html',
                url: null,
                content: '<section>C</section>',
                alt: null,
                title: 'HTML C',
              },
            },
            {
              label: '리소스안',
              value: 'resource-d',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/chat/resources/sample.html',
                content: null,
                alt: null,
                title: '리소스',
              },
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
  assert.deepEqual(
    extractChatMessageParts(
      '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
    ),
    {
      strippedText: '',
      parts: [
        {
          type: 'prompt',
          title: '시안 선택',
          description: null,
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: 'A안',
              value: 'option-a',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
                content: null,
                alt: null,
                title: null,
              },
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
  assert.deepEqual(
    extractChatMessageParts(
      '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
    ),
    {
      strippedText: '',
      parts: [
        {
          type: 'prompt',
          title: '시안 선택',
          description: null,
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: 'A안',
              value: 'option-a',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
                content: null,
                alt: null,
                title: null,
              },
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts supports stepper prompt steps', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '단계형 stepper prompt입니다.',
        '[[prompt:{"title":"구현 흐름 선택","description":"단계별로 실행 범위를 고릅니다.","steps":[{"key":"layout","title":"시안 선택","options":[{"label":"A안","value":"layout-a","description":"기본 레이아웃"},{"label":"B안","value":"layout-b","description":"탭 중심 레이아웃"}]},{"key":"scope","title":"후속 범위","optional":true,"multiple":true,"freeTextLabel":"세부 요청","options":[{"label":"모바일 정리","value":"mobile-cleanup"},{"label":"상태 요약 추가","value":"summary-card"}]}],"responseTemplate":"{{step_summaries}}"}]]',
      ].join('\n'),
    ),
    {
      strippedText: '단계형 stepper prompt입니다.',
      parts: [
        {
          type: 'prompt',
          title: '구현 흐름 선택',
          description: '단계별로 실행 범위를 고릅니다.',
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: '{{step_summaries}}',
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          steps: [
            {
              key: 'layout',
              title: '시안 선택',
              description: null,
              submitLabel: null,
              mode: null,
              multiple: false,
              optional: false,
              responseTemplate: null,
              freeTextLabel: null,
              freeTextPlaceholder: null,
              selectedValues: [],
              options: [
                {
                  label: 'A안',
                  value: 'layout-a',
                  description: '기본 레이아웃',
                  preview: null,
                },
                {
                  label: 'B안',
                  value: 'layout-b',
                  description: '탭 중심 레이아웃',
                  preview: null,
                },
              ],
            },
            {
              key: 'scope',
              title: '후속 범위',
              description: null,
              submitLabel: null,
              mode: null,
              multiple: true,
              optional: true,
              responseTemplate: null,
              freeTextLabel: '세부 요청',
              freeTextPlaceholder: null,
              selectedValues: [],
              options: [
                {
                  label: '모바일 정리',
                  value: 'mobile-cleanup',
                  description: null,
                  preview: null,
                },
                {
                  label: '상태 요약 추가',
                  value: 'summary-card',
                  description: null,
                  preview: null,
                },
              ],
            },
          ],
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          options: [],
        },
      ],
    },
  );
});

test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '결과 본문',
        '[[link-card:업무용 모바일 시안 미리보기|https:/api/chat/resources/.codex_chat/69784018-c492-4021-ad98-1751c8cc90da/resource/mobile-ui-samples.html%7C미리보기 열기]]',
      ].join('\n'),
    ),
    {
      strippedText: [
        '결과 본문',
        '/api/chat/resources/.codex_chat/69784018-c492-4021-ad98-1751c8cc90da/resource/mobile-ui-samples.html',
      ].join('\n'),
      parts: [],
    },
  );
});

test('extractChatMessageParts canonicalizes prompt preview resource urls from public paths and absolute filesystem paths', () => {
  assert.deepEqual(
    extractChatMessageParts(
      '[[prompt:{"title":"시안 선택","options":[{"label":"공개경로","value":"public-path","preview":{"type":"resource","url":"public/.codex_chat/chat-room/resource/sample-a.html"}},{"label":"절대경로","value":"absolute-path","preview":{"type":"resource","url":"/home/how2ice/project/ai-code-app/public/.codex_chat/chat-room/resource/sample-b.html"}},{"label":"닷경로","value":"dot-path","preview":{"type":"resource","url":"/.codex_chat/chat-room/resource/sample-c.html"}}]}]]',
    ),
    {
      strippedText: '',
      parts: [
        {
          type: 'prompt',
          title: '시안 선택',
          description: null,
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: '공개경로',
              value: 'public-path',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-a.html',
                content: null,
                alt: null,
                title: null,
              },
            },
            {
              label: '절대경로',
              value: 'absolute-path',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-b.html',
                content: null,
                alt: null,
                title: null,
              },
            },
            {
              label: '닷경로',
              value: 'dot-path',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-c.html',
                content: null,
                alt: null,
                title: null,
              },
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts canonicalizes prompt preview resource urls from resource manager registered paths', () => {
  assert.deepEqual(
    extractChatMessageParts(
      '[[prompt:{"title":"등록 리소스 선택","options":[{"label":"기능명세","value":"feature-spec","preview":{"type":"resource","url":"resource/Codex Live/리소스 관리/검색 및 아이콘 액션/20260513/docs/feature-spec.md"}}]}]]',
    ),
    {
      strippedText: '',
      parts: [
        {
          type: 'prompt',
          title: '등록 리소스 선택',
          description: null,
          submitLabel: null,
          mode: null,
          multiple: false,
          responseTemplate: null,
          freeTextLabel: null,
          freeTextPlaceholder: null,
          currentStepKey: null,
          readOnly: false,
          selectedValues: [],
          resolvedBy: null,
          resolvedAt: null,
          resultText: null,
          steps: undefined,
          options: [
            {
              label: '기능명세',
              value: 'feature-spec',
              description: null,
              preview: {
                type: 'resource',
                url: '/api/resource-manager/preview/Codex%20Live/%EB%A6%AC%EC%86%8C%EC%8A%A4%20%EA%B4%80%EB%A6%AC/%EA%B2%80%EC%83%89%20%EB%B0%8F%20%EC%95%84%EC%9D%B4%EC%BD%98%20%EC%95%A1%EC%85%98/20260513/docs/feature-spec.md',
                content: null,
                alt: null,
                title: null,
              },
            },
          ],
        },
      ],
    },
  );
});

test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
  assert.deepEqual(
    extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
    {
      strippedText: '결과 본문',
      parts: [
        {
          type: 'link_card',
          title: '판매글 열기',
          url: 'https://www.daangn.com/kr/buy-sell/mac-studio',
          actionLabel: null,
        },
      ],
    },
  );
});

test('extractChatMessageParts keeps angle-bracket internal resource markdown links as plain text', () => {
  assert.deepEqual(
    extractChatMessageParts(
      ['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
        '\n',
      ),
    ),
    {
      strippedText: ['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
        '\n',
      ),
      parts: [],
    },
  );
});

test('extractChatMessageParts promotes standalone markdown links with angle-bracket external targets into structured link cards', () => {
  assert.deepEqual(
    extractChatMessageParts('- [판매글 열기](<https://www.daangn.com/kr/buy-sell/mac studio>)'),
    {
      strippedText: '',
      parts: [
        {
          type: 'link_card',
          title: '판매글 열기',
          url: 'https://www.daangn.com/kr/buy-sell/mac studio',
          actionLabel: null,
        },
      ],
    },
  );
});

test('extractChatMessageParts promotes standalone urls with the previous line as the card title', () => {
  assert.deepEqual(
    extractChatMessageParts(
      [
        '수원 인근 공개 항목',
        '- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
        'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
      ].join('\n'),
    ),
    {
      strippedText: ['수원 인근 공개 항목', '- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원'].join('\n'),
      parts: [
        {
          type: 'link_card',
          title: '동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
          url: 'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
          actionLabel: null,
        },
      ],
    },
  );
});

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('summarizeActivityProgressLine prefers meaningful command reasons', () => {
  assert.equal(
    summarizeActivityProgressLine('# 이유: 관련 파일을 읽어 현재 구조를 파악합니다.\n$ sed -n \'1,40p\' AGENTS.md'),
    '관련 파일을 읽어 현재 구조를 파악합니다.',
  );
});

test('summarizeActivityProgressLine skips boilerplate status lines', () => {
  assert.equal(
    summarizeActivityProgressLine('# 상태: 요청을 처리합니다.\n# 상태: 응답 생성이 완료되었습니다.'),
    '',
  );
});

test('extractCodexStreamText ignores command execution item completions', () => {
  assert.deepEqual(
    extractCodexStreamText({
      type: 'item.completed',
      item: {
        id: 'item_1',
        type: 'command_execution',
        command: '/bin/bash -lc pwd',
        aggregated_output: '/workspace/main-project\n',
        exit_code: 0,
        status: 'completed',
      },
    }),
    {
      type: 'item.completed',
      completedText: '',
      deltaText: '',
    },
  );
});

test('parseStructuredCodexStdoutLine strips nested command execution JSON from raw failure text', () => {
  assert.deepEqual(
    parseStructuredCodexStdoutLine(
      JSON.stringify({
        type: 'item.completed',
        item: {
          id: 'item_13',
          type: 'command_execution',
          command: '/bin/bash -lc "sed -n \'1,220p\' /home/how2ice/.codex/config.toml"',
          aggregated_output: 'model = "gpt-5.4"',
          exit_code: 0,
          status: 'completed',
        },
      }),
    ),
    {
      activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"',
      completedText: '',
      deltaText: '',
      usageSnapshot: null,
      shouldKeepRaw: false,
    },
  );
});

test('parseStructuredCodexStdoutLine keeps usage snapshots from response.completed JSON', () => {
  assert.deepEqual(
    parseStructuredCodexStdoutLine(
      JSON.stringify({
        type: 'response.completed',
        response: {
          output: [
            {
              type: 'message',
              content: [{ type: 'output_text', text: '최종 응답입니다.' }],
            },
          ],
          usage: {
            input_tokens: 120,
            output_tokens: 45,
            cached_input_tokens: 30,
            reasoning_output_tokens: 10,
            total_tokens: 165,
          },
        },
      }),
    ),
    {
      activityLog: '',
      completedText: '최종 응답입니다.',
      deltaText: '',
      usageSnapshot: {
        tokenTotals: {
          total: 165,
          input: 120,
          output: 45,
          cached: 30,
          reasoning: 10,
        },
        totalTokens: 165,
      },
      shouldKeepRaw: false,
    },
  );
});

test('extractCodexStreamText keeps completed agent messages', () => {
  assert.deepEqual(
    extractCodexStreamText({
      type: 'item.completed',
      item: {
        id: 'item_2',
        type: 'agent_message',
        text: '최종 응답입니다.',
      },
    }),
    {
      type: 'item.completed',
      completedText: '최종 응답입니다.',
      deltaText: '',
    },
  );
});

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 strips document-style preview markers and missing resource previews', async () => {
  const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
  const markdownPath = path.join(repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'source', 'chat-room-reference.md');
  const imagePath = path.join(repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'captures', 'chat-mobile.png');
  await mkdir(path.dirname(markdownPath), { recursive: true });
  await mkdir(path.dirname(imagePath), { recursive: true });
  await writeFile(markdownPath, '# 참고 문서\n', 'utf8');
  await writeFile(imagePath, 'png', 'utf8');

  const rewritten = await rewriteCodexOutputWithChatResources(
    [
      '문서 preview는 제거되어야 합니다.',
      '[[preview:/api/chat/resources/.codex_chat/chat-room/resource/source/chat-room-reference.md]]',
      '[[preview:https:/api/chat/resources/.codex_chat/chat-room/resource/missing.md]]',
      '[[preview:/api/chat/resources/.codex_chat/chat-room/resource/captures/chat-mobile.png]]',
    ].join('\n'),
    repoPath,
    'chat-room',
  );

  assert.doesNotMatch(rewritten, /chat-room-reference\.md\]\]/);
  assert.doesNotMatch(rewritten, /missing\.md\]\]/);
  assert.match(rewritten, /\[\[preview:\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/captures\/chat-mobile\.png\]\]/);
});

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;
  }
});
