Files
ai-code-app/etc/servers/work-server/src/services/chat-service.ts

4774 lines
155 KiB
TypeScript

import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
import { getAppConfigSnapshot, getChatContextSettingsConfig, getChatTypesConfig } from './app-config-service.js';
import { BOARD_POSTS_TABLE } from './board-service.js';
import {
appendChatConversationMessage,
appendChatConversationActivityLine,
getChatConversationRequest,
getChatConversation,
listChatConversationOfflineNotificationClientIds,
listRecoverableChatConversationRequests,
listChatConversationMessages,
listChatConversationRequests,
upsertChatConversationRequest,
updateChatConversationJobState,
updateChatConversationContext,
} from './chat-room-service.js';
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js';
import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { sendNotifications } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
subscribeNotificationMessageChanges,
type NotificationMessageChangeEvent,
} from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
findLatestPlanItem,
findPlanItemByPreviewUrl,
findPlanItemByWorkId,
getPlanItemById,
listPlanActionHistories,
listPlanIssueHistories,
listPlanSourceWorkHistories,
mapPlanActionRow,
mapPlanIssueRow,
mapPlanSourceWorkRow,
PLAN_TABLE,
} from './plan-service.js';
type ChatAuthor = 'codex' | 'system' | 'user';
type ChatMessage = {
id: number;
author: ChatAuthor;
text: string;
timestamp: string;
clientRequestId?: string | null;
parts?: ChatMessagePart[];
};
type ChatContext = {
pageId: string | null;
pageTitle: string;
topMenu: string;
focusedComponentId: string | null;
pageUrl: string;
isStandaloneMode?: boolean;
pageVisibilityState?: 'visible' | 'hidden';
pageFocusState?: 'focused' | 'blurred';
chatTypeId?: string | null;
chatTypeLabel?: string;
chatTypeDescription?: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id?: string;
title?: string;
content?: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
};
type ChatInboundMessage =
| {
type: 'context:update';
payload: ChatContext;
}
| {
type: 'presence:ping';
payload?: {
at?: number;
};
}
| {
type: 'event:received';
payload: {
eventId: number;
};
}
| {
type: 'message:send';
payload: {
text: string;
requestId?: string;
mode?: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId?: string | null;
chatTypeLabel?: string;
chatTypeDescription?: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id?: string;
title?: string;
content?: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
};
}
| {
type: 'runtime:watch';
payload: {
requestId?: string | null;
};
};
type ChatOutboundPayload =
| {
type: 'chat:init';
payload: {
messages: ChatMessage[];
};
}
| {
type: 'chat:message';
payload: ChatMessage;
}
| {
type: 'chat:message:update';
payload: ChatMessage;
}
| {
type: 'chat:status';
payload: {
connectedAt: string;
};
}
| {
type: 'chat:job';
payload: {
requestId: string;
status: 'queued' | 'started' | 'completed' | 'failed';
mode: 'queue' | 'direct';
queueSize: number;
message: string;
};
}
| {
type: 'chat:error';
payload: {
message: string;
};
}
| {
type: 'chat:runtime';
payload: ChatRuntimeSnapshot;
}
| {
type: 'chat:runtime:detail';
payload: ChatRuntimeJobDetail;
}
| {
type: 'chat:activity';
payload: {
requestId: string;
line: string;
lineCount: number;
lineNo?: number;
};
}
| {
type: 'notification:messages-updated';
payload:
| {
action: 'created' | 'updated';
itemId: number;
category: string;
read: boolean;
}
| {
action: 'deleted';
itemId: number;
};
};
type ChatOutboundMessage = ChatOutboundPayload & {
eventId: number;
sessionId: string;
};
type ChatSessionState = {
sessionId: string;
clientId: string | null;
socket: WebSocket | null;
lastSeenAt: number;
isDeleted: boolean;
context: ChatContext | null;
queue: Array<{
requestId: string;
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
omitPromptHistory?: boolean;
context: ChatContext | null;
}>;
activeRequestCount: number;
pendingQueueReleaseEventId: number | null;
nextEventId: number;
eventHistory: ChatOutboundMessage[];
messagePersistenceTail: Promise<void>;
watchedRuntimeRequestId: string | null;
};
type ChatRuntimeController = {
getJobDetail: (requestId: string) => ReturnType<typeof chatRuntimeService.getJobDetail>;
cancelJob: (requestId: string) => Promise<boolean>;
removeQueuedJob: (requestId: string) => Promise<boolean>;
};
type ActiveChatExecution = {
cancel: () => Promise<boolean> | boolean;
};
let activeRuntimeController: ChatRuntimeController | null = null;
let activeChatService: ChatService | null = null;
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
export function getChatRuntimeController() {
return activeRuntimeController;
}
export function getActiveChatService() {
return activeChatService;
}
const SOCKET_PATH = '/ws/chat';
const KST_TIME_ZONE = 'Asia/Seoul';
const STREAM_CAPTURE_LIMIT = 256 * 1024;
const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat';
const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md';
const CHAT_SESSION_REFERENCE_AUTO_START = '<!-- codex-live:auto:start -->';
const CHAT_SESSION_REFERENCE_AUTO_END = '<!-- codex-live:auto:end -->';
const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777;
const SOCKET_READY_STATE_OPEN = 1;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240;
const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000;
const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000;
const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12;
const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200;
const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400;
let chatMessageSequence = 0;
export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>) {
return evaluateChatClientActiveViewing(clientId, sessions).isActive;
}
function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>, nowMs = Date.now()) {
const normalizedClientId = clientId?.trim();
if (!normalizedClientId) {
return {
isActive: false,
matchedSessions: [],
};
}
const matchedSessions: Array<{
sessionId: string;
lastSeenAt: number;
presenceAgeMs: number;
socketReadyState: number | null;
pageVisibilityState: 'visible' | 'hidden';
pageFocusState: 'focused' | 'blurred';
pageOrigin: string | null;
reason:
| 'socket-not-open'
| 'stale-presence'
| 'backgrounded'
| 'active-no-page-url'
| 'active-supported-origin'
| 'unsupported-origin'
| 'invalid-page-url';
}> = [];
for (const session of sessions) {
if (session.clientId?.trim() !== normalizedClientId) {
continue;
}
const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible';
const pageFocusState = session.context?.pageFocusState ?? 'focused';
const pageUrl = session.context?.pageUrl?.trim();
const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt);
const socketReadyState = session.socket?.readyState ?? null;
if (!session.socket || session.socket.readyState !== SOCKET_READY_STATE_OPEN) {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: null,
reason: 'socket-not-open',
});
continue;
}
const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS;
if (!hasFreshPresence) {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: null,
reason: 'stale-presence',
});
continue;
}
if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: null,
reason: 'backgrounded',
});
continue;
}
if (!pageUrl) {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: null,
reason: 'active-no-page-url',
});
return {
isActive: true,
matchedSessions,
};
}
try {
const resolvedUrl = new URL(pageUrl);
if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: resolvedUrl.origin,
reason: 'active-supported-origin',
});
return {
isActive: true,
matchedSessions,
};
}
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: resolvedUrl.origin,
reason: 'unsupported-origin',
});
} catch {
matchedSessions.push({
sessionId: session.sessionId,
lastSeenAt: session.lastSeenAt,
presenceAgeMs,
socketReadyState,
pageVisibilityState,
pageFocusState,
pageOrigin: null,
reason: 'invalid-page-url',
});
continue;
}
}
return {
isActive: false,
matchedSessions,
};
}
function logChatClientActiveViewingEvaluation(
logger: FastifyBaseLogger,
clientId: string,
evaluation: ReturnType<typeof evaluateChatClientActiveViewing>,
) {
if (evaluation.matchedSessions.length === 0) {
logger.info(
{
clientId,
active: false,
reason: 'no-matching-session',
},
'chat offline notification presence evaluation',
);
return;
}
for (const sessionState of evaluation.matchedSessions) {
logger.info(
{
clientId,
active: evaluation.isActive,
sessionId: sessionState.sessionId,
lastSeenAt: sessionState.lastSeenAt,
presenceAgeMs: sessionState.presenceAgeMs,
socketReadyState: sessionState.socketReadyState,
pageVisibilityState: sessionState.pageVisibilityState,
pageFocusState: sessionState.pageFocusState,
pageOrigin: sessionState.pageOrigin,
reason: sessionState.reason,
},
'chat offline notification presence evaluation',
);
}
}
export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) {
const pageUrl = context?.pageUrl?.trim();
if (!pageUrl) {
return null;
}
try {
return new URL(pageUrl).origin;
} catch {
return null;
}
}
function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) {
const fallbackUrl = new URL('https://preview.sm-home.cloud/chat/live');
fallbackUrl.searchParams.set('topMenu', 'chat');
fallbackUrl.searchParams.set('sessionId', sessionId);
const pageUrl = context?.pageUrl?.trim();
if (!pageUrl) {
return fallbackUrl.toString();
}
try {
const targetUrl = new URL(pageUrl);
targetUrl.pathname = '/chat/live';
targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
targetUrl.searchParams.set('sessionId', sessionId);
targetUrl.searchParams.delete('chatView');
targetUrl.searchParams.delete('runtimeRequestId');
return targetUrl.toString();
} catch {
return fallbackUrl.toString();
}
}
function createChatNotificationPreview(text: string) {
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
}
function createChatQuestionAnswerNotificationBody(args: {
questionText?: string | null;
answerText?: string | null;
fallback: string;
}) {
const questionPreview = createChatNotificationPreview(args.questionText ?? '');
const answerPreview = createChatNotificationPreview(args.answerText ?? '');
if (questionPreview && answerPreview) {
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
}
if (answerPreview) {
return `답변: ${answerPreview}`;
}
if (questionPreview) {
return `질문: ${questionPreview}`;
}
return args.fallback;
}
function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage {
if (message.author === 'user') {
return message;
}
const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : [];
const extracted = extractChatMessageParts(message.text);
const nextParts = existingParts.length > 0 ? existingParts : extracted.parts;
if (nextParts.length === 0) {
return existingParts.length === 0 ? message : { ...message, parts: existingParts };
}
return {
...message,
text: extracted.strippedText,
parts: nextParts,
};
}
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
const questionPreview = createChatNotificationPreview(questionText ?? '');
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
}
function normalizeNotificationDetailText(text?: string | null) {
const normalized = String(text ?? '').trim();
return normalized || undefined;
}
export function collectOfflineNotificationClientIds(sessionClientId?: string | null, preferredClientIds?: string[]) {
const nextClientIds = new Set<string>();
for (const candidate of preferredClientIds ?? []) {
const normalized = String(candidate ?? '').trim();
if (normalized) {
nextClientIds.add(normalized);
}
}
const normalizedSessionClientId = String(sessionClientId ?? '').trim();
if (normalizedSessionClientId) {
nextClientIds.add(normalizedSessionClientId);
}
return [...nextClientIds];
}
export function filterInactiveOfflineNotificationClientIds(
clientIds: string[],
sessions: Iterable<ChatSessionState>,
isActive: (clientId: string, currentSessions: Iterable<ChatSessionState>) => boolean = isChatClientActivelyViewing,
) {
return clientIds.filter((clientId) => !isActive(clientId, sessions));
}
export function shouldSendOfflineChatNotification(options: {
receiveRoomNotifications?: boolean | null;
conversationNotifyOffline?: boolean | null;
}) {
if (options.receiveRoomNotifications === false) {
return false;
}
return options.conversationNotifyOffline === true;
}
function isPreparingChatReply(text?: string | null) {
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
return normalized.startsWith('응답을 준비하고 있습니다');
}
function formatTime(date: Date) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: KST_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.format(date)
.replace(',', '');
}
export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) {
if (!Number.isFinite(requestedAtMs)) {
return formatTime(new Date(nowMs));
}
return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000)));
}
function parseRequestedAtMs(value: string) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Date.now();
}
function formatKstDate(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: KST_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function createChatMessageId() {
chatMessageSequence = (chatMessageSequence + 1) % 1_000;
return Date.now() * 1_000 + chatMessageSequence;
}
function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage {
return {
id: createChatMessageId(),
author,
text,
timestamp: formatTime(new Date()),
clientRequestId: clientRequestId?.trim() || null,
parts: Array.isArray(parts) ? parts : [],
};
}
function createRequestId() {
return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) {
const queryToken = url.searchParams.get('accessToken')?.trim();
const headerToken = Array.isArray(request.headers['x-access-token'])
? String(request.headers['x-access-token'][0] ?? '').trim()
: String(request.headers['x-access-token'] ?? '').trim();
return hasErrorLogViewAccessToken(queryToken || headerToken);
}
function hashRequestId(value: string) {
let hash = 0;
for (const character of value) {
hash = (hash * 31 + character.charCodeAt(0)) | 0;
}
return Math.abs(hash) + 1_000_000;
}
export function fitActivityLogLines(lines: string[]) {
const normalizedLines = lines.map((line) => line.trim()).filter(Boolean);
if (normalizedLines.length <= 1) {
return normalizedLines;
}
let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES);
let fittedLines = normalizedLines.slice(startIndex);
while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) {
startIndex += 1;
fittedLines = normalizedLines.slice(startIndex);
}
return fittedLines;
}
export function createActivityLogMessage(requestId: string, lines: string[]) {
const normalizedRequestId = requestId.trim();
const fittedLines = fitActivityLogLines(lines);
const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : '활동 로그를 준비하고 있습니다.';
const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`;
return createMessage('system', messageText, normalizedRequestId)
? {
id: hashRequestId(normalizedRequestId),
author: 'system' as const,
text: messageText,
timestamp: formatTime(new Date()),
clientRequestId: normalizedRequestId,
}
: null;
}
function shouldPersistMessageUpdate(message: ChatMessage) {
if (message.author === 'codex') {
return false;
}
return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
function isSocketOpen(socket: WebSocket | null | undefined) {
return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN);
}
function closeSocketSafely(
logger: FastifyBaseLogger,
socket: WebSocket | null | undefined,
message: string,
code = 1000,
reason = 'replaced',
) {
if (!socket) {
return;
}
try {
socket.close(code, reason);
} catch (error) {
logger.warn(error, message);
}
}
function sendSocketEnvelope(
logger: FastifyBaseLogger,
socket: WebSocket | null | undefined,
envelope: ChatOutboundMessage,
message: string,
) {
const targetSocket = socket;
if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) {
return false;
}
try {
targetSocket.send(JSON.stringify(envelope));
return true;
} catch (error) {
logger.warn(error, message);
return false;
}
}
type ParsedPlanContext = {
planId: number | null;
workId: string | null;
previewUrl: string | null;
};
type PlanSnapshot = {
planId: number;
workId: string;
status: string;
note: string;
workerStatus: string | null;
issueTags: string[];
hasOpenIssues: boolean;
assignedBranch: string | null;
lastError: string | null;
latestActionNote: string | null;
latestIssue: {
tag: string;
message: string;
resolved: boolean;
} | null;
latestSourceWork: {
summary: string;
previewUrl: string | null;
changedFiles: string[];
} | null;
recentActionNotes: string[];
recentWorkSummaries: string[];
};
function truncateText(value: string | null | undefined, limit = 120) {
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return '';
}
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}` : normalized;
}
function extractReferencedPlanId(input: string) {
const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i);
const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN;
return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null;
}
function extractReferencedWorkId(input: string) {
const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i);
return workIdMatch?.[1]?.trim() || null;
}
function extractReferencedPreviewUrl(input: string) {
const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i);
return previewUrlMatch?.[0]?.trim() || null;
}
function buildChangedFilesSummary(changedFiles: string[], limit = 5) {
if (changedFiles.length === 0) {
return '변경/신규 파일: 기록 없음';
}
const visibleFiles = changedFiles.slice(0, limit);
const suffix = changedFiles.length > limit ? `${changedFiles.length - limit}` : '';
return `변경/신규 파일: ${visibleFiles.join(', ')}${suffix}`;
}
function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext {
const pageUrl = context?.pageUrl ?? '';
const referencedPlanId = extractReferencedPlanId(input);
const referencedWorkId = extractReferencedWorkId(input);
const referencedPreviewUrl = extractReferencedPreviewUrl(input);
if (!pageUrl) {
return {
planId: referencedPlanId,
workId: referencedWorkId,
previewUrl: referencedPreviewUrl,
};
}
try {
const url = new URL(pageUrl);
const planIdText = url.searchParams.get('planId');
const workIdText = url.searchParams.get('workId');
const planIdNumber = planIdText ? Number(planIdText) : NaN;
return {
planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId,
workId: workIdText?.trim() || referencedWorkId,
previewUrl: referencedPreviewUrl,
};
} catch {
return {
planId: referencedPlanId,
workId: referencedWorkId,
previewUrl: referencedPreviewUrl,
};
}
}
async function loadPlanSnapshot(planId: number): Promise<PlanSnapshot | null> {
const item = await getPlanItemById(planId);
if (!item) {
return null;
}
const [actionRows, issueRows, sourceWorkRows] = await Promise.all([
listPlanActionHistories(planId),
listPlanIssueHistories(planId),
listPlanSourceWorkHistories(planId),
]);
const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null;
const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null;
const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null;
return {
planId,
workId: String(item.workId ?? '작업ID'),
status: String(item.status ?? '-'),
note: String(item.note ?? ''),
workerStatus: item.workerStatus ? String(item.workerStatus) : null,
issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [],
hasOpenIssues: Boolean(item.hasOpenIssues),
assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null,
lastError: item.lastError ? String(item.lastError) : null,
latestActionNote: latestAction ? String(latestAction.note) : null,
latestIssue: latestIssue
? {
tag: String(latestIssue.issueTag),
message: String(latestIssue.message),
resolved: Boolean(latestIssue.resolved),
}
: null,
latestSourceWork: latestSourceWork
? {
summary: String(latestSourceWork.summary),
previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null,
changedFiles: latestSourceWork.changedFiles,
}
: null,
recentActionNotes: actionRows
.slice(0, 5)
.map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140))
.filter(Boolean),
recentWorkSummaries: sourceWorkRows
.slice(0, 5)
.map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140))
.filter(Boolean),
};
}
function isWorklogRequest(input: string) {
const normalized = input.toLowerCase();
const mentionsWorklog =
input.includes('워크일지') ||
input.includes('작업로그') ||
input.includes('작업 일지') ||
input.includes('작업일지') ||
normalized.includes('worklog');
const asksToWrite =
input.includes('작성') ||
input.includes('정리') ||
input.includes('기록') ||
input.includes('밀린') ||
input.includes('써');
return mentionsWorklog || (normalized.includes('log') && asksToWrite);
}
function isPlanDetailRequest(input: string) {
const normalized = input.toLowerCase();
return (
input.includes('작업') ||
input.includes('계획') ||
input.includes('이력') ||
input.includes('이슈') ||
input.includes('상태') ||
input.includes('브랜치') ||
normalized.includes('plan') ||
normalized.includes('issue') ||
normalized.includes('status') ||
normalized.includes('history') ||
normalized.includes('branch')
);
}
function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) {
const pageTitle = context?.pageTitle ?? '현재 화면';
if (!snapshot) {
return `현재 화면: ${pageTitle}\n작업로그 초안을 만들 Plan을 찾지 못했습니다. Plan 상세를 연 뒤 다시 요청해 주세요.`;
}
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const todayWork = [
truncateText(snapshot.note, 180),
...snapshot.recentWorkSummaries,
snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '',
].filter(Boolean);
const issues = snapshot.latestIssue
? [
`${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? '해결' : '미해결'}: ${truncateText(
snapshot.latestIssue.message,
140,
)}`,
]
: [];
const decisions = [
snapshot.assignedBranch ? `작업 브랜치: ${snapshot.assignedBranch}` : '',
snapshot.latestSourceWork?.previewUrl ? `preview 링크: ${snapshot.latestSourceWork.previewUrl}` : '',
snapshot.status ? `현재 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '',
].filter(Boolean);
const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean);
return [
`현재 화면: ${pageTitle}`,
`기준 Plan: #${snapshot.planId} ${snapshot.workId}`,
'아래 초안을 작업일지에 바로 붙여 넣으면 됩니다.',
'',
`# ${today} 작업일지`,
'',
'## 오늘 작업',
...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan 메모와 이력 확인 후 작업 내용을 보강해 주세요.']),
'',
'## 이슈 및 해결',
...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- 현재 기록된 이슈 없음']),
'',
'## 결정 사항',
...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- 별도 결정 사항 없음']),
'',
'## 상세 작업 내역',
...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- 최근 조치 이력이 없어 상세 내역 보강이 필요합니다.']),
].join('\n');
}
function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) {
if (!snapshot) {
return [];
}
const historyItems = [
...snapshot.recentActionNotes.map((note) => `[조치] ${note}`),
...snapshot.recentWorkSummaries.map((summary) => `[소스] ${summary}`),
]
.filter(Boolean)
.slice(0, limit);
if (historyItems.length === 0) {
return [];
}
return ['최근 조치 이력:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)];
}
function isDetailRequest(input: string) {
const normalized = input.toLowerCase();
return (
input.includes('상세') ||
input.includes('자세히') ||
input.includes('전체') ||
normalized.includes('detail') ||
normalized.includes('details') ||
normalized.includes('more')
);
}
export function isAutomationRegistrationCountRequest(input: string) {
const normalized = input.toLowerCase();
const mentionsAutomation =
input.includes('자동화') || normalized.includes('automation') || normalized.includes('plan');
const mentionsRegistration =
input.includes('등록') || input.includes('접수') || normalized.includes('register') || normalized.includes('count');
const mentionsToday =
input.includes('오늘') || normalized.includes('today');
const asksCount =
input.includes('총') ||
input.includes('건수') ||
input.includes('몇 건') ||
input.includes('몇건') ||
normalized.includes('count') ||
normalized.includes('total');
return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount);
}
function isAutomationRegistrationDefinitionRequest(input: string) {
const normalized = input.toLowerCase();
const mentionsAutomation = input.includes('자동화') || normalized.includes('automation');
const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register');
const asksMeaning =
input.includes('무슨 뜻') ||
input.includes('뭐야') ||
input.includes('뜻') ||
input.includes('기준') ||
input.includes('뭘 의미') ||
normalized.includes('meaning') ||
normalized.includes('define');
return mentionsAutomation && mentionsRegistration && asksMeaning;
}
export function shouldUseAgenticCodexReply(input: string) {
const normalized = input.toLowerCase();
const trimmed = input.trim();
if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) {
return false;
}
if (!trimmed) {
return false;
}
if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) {
return false;
}
if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) {
return false;
}
return (
input.includes('수정') ||
input.includes('변경') ||
input.includes('구현') ||
input.includes('추가') ||
input.includes('삭제') ||
input.includes('고쳐') ||
input.includes('만들어') ||
input.includes('등록해줘') ||
input.includes('조회') ||
input.includes('확인') ||
input.includes('읽어') ||
input.includes('분석') ||
input.includes('DB') ||
input.includes('API') ||
input.includes('소스') ||
input.includes('파일') ||
input.includes('로그') ||
/(?:\/|\\).+\.[a-z0-9]+/i.test(input) ||
normalized.includes('fix') ||
normalized.includes('implement') ||
normalized.includes('change') ||
normalized.includes('edit') ||
normalized.includes('read') ||
normalized.includes('source') ||
normalized.includes('file') ||
normalized.includes('db') ||
normalized.includes('api') ||
/\s/.test(trimmed)
);
}
export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) {
void context;
void input;
return false;
}
function summarizeCodexOutput(output: string) {
const normalized = String(output ?? '').trim();
if (!normalized) {
return 'Codex 실행 결과가 비어 있습니다.';
}
const lines = normalized
.split('\n')
.map((line) => line.trimEnd())
.filter(Boolean);
return lines.slice(-12).join('\n');
}
class ChatRuntimeExecutionError extends Error {
responseText: string;
constructor(message: string, responseText = '') {
super(message);
this.name = 'ChatRuntimeExecutionError';
this.responseText = responseText.trim();
}
}
function summarizeCommand(command: string, limit = 180) {
const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return '';
}
return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized;
}
function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) {
const lines = String(output ?? '')
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return '';
}
const joined = lines.slice(0, maxLines).join(' / ');
return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined;
}
function inferCommandReason(command: string) {
const normalized = command.toLowerCase();
if (normalized.includes('rg ') || normalized.includes('ripgrep')) {
return '관련 파일이나 텍스트를 빠르게 찾기 위해 검색했습니다.';
}
if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) {
return '해당 파일 내용을 직접 읽어 문맥을 확인했습니다.';
}
if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) {
return '디렉터리 구조와 대상 파일 위치를 확인했습니다.';
}
if (normalized.includes('git status')) {
return '작업 트리 상태와 변경 범위를 점검했습니다.';
}
if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) {
return '수정 후 타입이나 동작 검증을 진행했습니다.';
}
if (normalized.includes('curl ') || normalized.includes('fetch')) {
return '실제 응답이나 연결 상태를 확인했습니다.';
}
return '현재 요청을 해결하기 위한 다음 단계를 확인했습니다.';
}
function isIgnorableCodexDiagnosticLine(line: string) {
const normalized = line.trim();
if (!normalized) {
return true;
}
return (
/^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) ||
normalized.includes('ignoring interface.defaultPrompt') ||
normalized.includes('failed to open state db') ||
normalized.includes('state db discrepancy') ||
normalized.includes('Failed to kill MCP process group') ||
normalized.includes('Failed to delete shell snapshot') ||
normalized.includes('failed to unwatch ')
);
}
function extractCodexActivityLog(parsed: Record<string, unknown>) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record<string, unknown>) : null;
const itemType = typeof item?.type === 'string' ? item.type : '';
if (!item || itemType !== 'command_execution') {
return '';
}
const command = summarizeCommand(typeof item.command === 'string' ? item.command : '');
if (!command) {
return '';
}
const reason = inferCommandReason(command);
if (type === 'item.started') {
return `# 이유: ${reason}\n$ ${command}`;
}
if (type === 'item.completed') {
const exitCode =
typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null;
const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : '');
const statusLabel = exitCode === null ? '# 결과: 완료' : exitCode === 0 ? '# 결과: 완료(0)' : `# 결과: 종료(${exitCode})`;
return outputSummary ? `${statusLabel}\n# 출력: ${outputSummary}` : statusLabel;
}
return '';
}
function normalizeCodexReplyOutput(output: string) {
const normalized = String(output ?? '').trim();
return normalized || 'Codex 실행 결과가 비어 있습니다.';
}
function collectCodexTextFragments(value: unknown): string[] {
if (typeof value === 'string') {
const normalized = value.trim();
return normalized ? [normalized] : [];
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectCodexTextFragments(item));
}
if (!value || typeof value !== 'object') {
return [];
}
const record = value as Record<string, unknown>;
const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message'];
for (const key of directTextKeys) {
const fragments = collectCodexTextFragments(record[key]);
if (fragments.length > 0) {
return fragments;
}
}
if (typeof record.type === 'string' && record.type.includes('output_text')) {
const fragments = collectCodexTextFragments(record.text ?? record.delta);
if (fragments.length > 0) {
return fragments;
}
}
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
}
function extractCompletedAgentMessageText(item: unknown) {
if (!item || typeof item !== 'object') {
return '';
}
const record = item as Record<string, unknown>;
if (record.type !== 'agent_message') {
return '';
}
return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
}
export function extractCodexStreamText(parsed: Record<string, unknown>) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') {
const completedText = extractCompletedAgentMessageText(parsed.item);
return {
type,
completedText,
deltaText: '',
};
}
if (type === 'item.delta' || type === 'response.output_text.delta') {
const deltaText =
type === 'response.output_text.delta'
? collectCodexTextFragments(parsed.delta ?? parsed.text).join('')
: collectCodexTextFragments(parsed.delta).join('');
return {
type,
completedText: '',
deltaText,
};
}
if (type === 'response.completed') {
const completedText = collectCodexTextFragments(parsed.response).join('');
return {
type,
completedText,
deltaText: '',
};
}
return {
type,
completedText: '',
deltaText: '',
};
}
export function parseStructuredCodexStdoutLine(line: string) {
const normalizedLine = String(line ?? '').trim();
if (!normalizedLine) {
return {
activityLog: '',
completedText: '',
deltaText: '',
shouldKeepRaw: false,
};
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(normalizedLine) as Record<string, unknown>;
} catch {
return {
activityLog: '',
completedText: '',
deltaText: '',
shouldKeepRaw: true,
};
}
const activityLog = extractCodexActivityLog(parsed);
const streamText = extractCodexStreamText(parsed);
const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText;
return {
activityLog,
completedText: streamText.completedText,
deltaText: streamText.deltaText,
shouldKeepRaw,
};
}
async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) {
const normalized = normalizeCodexReplyOutput(text);
if (!onProgress) {
return normalized;
}
for (let index = chunkSize; index < normalized.length; index += chunkSize) {
onProgress(normalized.slice(0, index));
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
onProgress(normalized);
return normalized;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripTrailingLineInfo(value: string) {
return value.replace(/:\d+(?::\d+)?$/, '');
}
function trimPathCandidate(value: string) {
return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, '');
}
function encodeUrlPathSegments(value: string) {
return value
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN =
/^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/;
const CHAT_RESOURCE_ROOT_FILE_PATTERN =
/^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/;
function isLikelyRepoRelativeChatResourcePath(candidate: string) {
const normalized = candidate.replace(/^\/+/, '');
return (
CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) ||
CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized)
);
}
function buildChatResourcePublicUrl(relativePath: string) {
const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`;
}
function normalizeEmbeddedChatResourceUrls(text: string) {
return String(text ?? '')
.replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1')
.replace(
/(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g,
(_match, resourcePath) => resourcePath,
);
}
function toPosixPath(value: string) {
return value.split(path.sep).join('/');
}
function normalizeExistingChatPublicUrl(candidate: string) {
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()))
.replace(/^\/+/, '')
.replace(/^public\//, '');
if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
return null;
}
return buildChatResourcePublicUrl(cleaned);
}
export function extractDiffCodeBlocks(output: string) {
const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g));
return matches
.map((match) => (typeof match[1] === 'string' ? match[1].trim() : ''))
.filter(Boolean);
}
function protectDiffCodeBlocks(output: string) {
const blocks: string[] = [];
const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
blocks.push(match);
return token;
});
return { text, blocks };
}
function restoreDiffCodeBlocks(output: string, blocks: string[]) {
return blocks.reduce(
(current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
String(output ?? ''),
);
}
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
if (!cleaned) {
return null;
}
let sourcePath: string | null = null;
if (cleaned.startsWith(repoPath)) {
sourcePath = cleaned;
} else if (cleaned.startsWith('/')) {
if (isLikelyRepoRelativeChatResourcePath(cleaned)) {
sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, ''));
} else {
sourcePath = cleaned;
}
} else {
sourcePath = path.resolve(repoPath, cleaned);
}
const normalizedRepoPath = path.resolve(repoPath);
const normalizedSourcePath = path.resolve(sourcePath);
const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
try {
const sourceStat = await stat(normalizedSourcePath);
if (!sourceStat.isFile()) {
return null;
}
} catch {
return null;
}
return {
absolutePath: normalizedSourcePath,
relativePath: toPosixPath(relativePath),
};
}
export function isChatResourcePathCandidate(candidate: string) {
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
if (!cleaned) {
return false;
}
if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
return true;
}
return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned);
}
export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) {
const existingPublicUrl = normalizeExistingChatPublicUrl(candidate);
if (existingPublicUrl) {
return existingPublicUrl;
}
const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate);
if (!resolvedSource) {
return null;
}
await ensureChatSessionResourceDirectories(repoPath, sessionId);
const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`;
const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true });
return buildChatResourcePublicUrl(targetRelativePath);
}
async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) {
const diffBlocks = extractDiffCodeBlocks(output);
if (diffBlocks.length === 0) {
return [];
}
const urls: string[] = [];
await ensureChatSessionResourceDirectories(repoPath, sessionId);
for (const [index, diffText] of diffBlocks.entries()) {
const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`;
const targetRelativePath =
`${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`;
const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8');
urls.push(buildChatResourcePublicUrl(targetRelativePath));
}
return urls;
}
async function ensureWorldWritableDirectory(absolutePath: string) {
await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE });
await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {});
}
export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) {
const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId);
const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR);
const sourceRoot = path.join(resourceRoot, 'source');
const uploadRoot = path.join(resourceRoot, 'uploads');
await ensureWorldWritableDirectory(sessionRoot);
await ensureWorldWritableDirectory(resourceRoot);
await ensureWorldWritableDirectory(sourceRoot);
await ensureWorldWritableDirectory(uploadRoot);
return {
sessionRoot,
resourceRoot,
sourceRoot,
uploadRoot,
};
}
function appendDiffResourceLinks(output: string, diffUrls: string[]) {
if (diffUrls.length === 0) {
return output;
}
const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url));
if (uniqueUrls.length === 0) {
return output;
}
const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
return `${output}\n\n${hiddenPreviewTags}`;
}
function isPreviewEligibleChatResourceUrl(url: string) {
const pathname = url.split('?')[0]?.toLowerCase() ?? '';
return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?)$/i.test(pathname);
}
function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) {
const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim();
const relativeUrl = normalizedUrl.replace(/^\/+/, '');
const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`;
if (!relativeUrl.startsWith(routePrefix)) {
return null;
}
const encodedResourcePath = relativeUrl.slice(routePrefix.length);
try {
const decodedPath = encodedResourcePath
.split('/')
.filter(Boolean)
.map((segment) => decodeURIComponent(segment))
.join('/');
if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
return null;
}
return path.resolve(repoPath, 'public', decodedPath);
} catch {
return null;
}
}
async function doesChatResourceUrlExist(repoPath: string, url: string) {
const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url);
if (!absolutePath) {
return false;
}
try {
const fileStat = await stat(absolutePath);
return fileStat.isFile();
} catch {
return false;
}
}
async function sanitizeChatResourcePresentation(output: string, repoPath: string) {
let sanitized = normalizeEmbeddedChatResourceUrls(output);
const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi));
const previewReplacementMap = new Map<string, string>();
for (const match of previewMatches) {
const marker = match[0] ?? '';
const rawUrl = match[1]?.trim() ?? '';
if (!marker || previewReplacementMap.has(marker)) {
continue;
}
const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl);
const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl);
const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl);
previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : '');
}
for (const [source, target] of previewReplacementMap.entries()) {
sanitized = sanitized.replaceAll(source, target);
}
return sanitized.replace(/\n{3,}/g, '\n\n').trim();
}
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
const candidatePattern = new RegExp(
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
'g',
);
const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
let rewrittenOutput = outputWithoutDiffBlocks;
if (matches.length > 0) {
const replacementMap = new Map<string, string>();
for (const match of matches) {
const rawCandidate = match[0]?.trim();
if (!rawCandidate || replacementMap.has(rawCandidate)) {
continue;
}
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
if (stagedUrl) {
replacementMap.set(rawCandidate, stagedUrl);
}
}
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
for (const [sourcePath, publicUrl] of replacements) {
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
}
}
rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath);
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath);
}
function normalizeChatPromptHistoryText(text: string) {
return String(text ?? '').replace(/\s+/g, ' ').trim();
}
function normalizePromptHistoryMessageLimit(value: number | undefined) {
if (value === undefined || !Number.isFinite(value)) {
return CHAT_PROMPT_HISTORY_MAX_MESSAGES;
}
return Math.min(50, Math.max(1, Math.round(value)));
}
function normalizePromptHistoryCharLimit(value: number | undefined) {
if (value === undefined || !Number.isFinite(value)) {
return CHAT_PROMPT_HISTORY_MAX_CHARS;
}
return Math.min(20_000, Math.max(500, Math.round(value)));
}
async function buildRecentChatPromptHistory(
sessionId: string,
requestId: string,
limits?: {
maxMessages?: number;
maxChars?: number;
disabled?: boolean;
},
) {
if (limits?.disabled) {
return {
items: [],
omittedCount: 0,
};
}
const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages);
const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars);
const messages = await listChatConversationMessages(sessionId, { limit: 80 });
const relevantMessages = messages.filter((message: (typeof messages)[number]) => {
if (message.clientRequestId?.trim() === requestId.trim()) {
return false;
}
if (message.author !== 'user' && message.author !== 'codex') {
return false;
}
return Boolean(normalizeChatPromptHistoryText(message.text));
});
const selectedMessages: typeof relevantMessages = [];
let totalChars = 0;
for (let index = relevantMessages.length - 1; index >= 0; index -= 1) {
const message = relevantMessages[index];
const text = normalizeChatPromptHistoryText(message.text);
const nextChars = text.length;
if (
selectedMessages.length >= maxMessages ||
(selectedMessages.length > 0 && totalChars + nextChars > maxChars)
) {
break;
}
selectedMessages.unshift(message);
totalChars += nextChars;
}
return {
items: selectedMessages.map(
(message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`,
),
omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length),
};
}
function cloneChatContext(context: ChatContext | null): ChatContext | null {
return context ? { ...context } : null;
}
function resolvePromptChatTypeLabel(context: ChatContext | null) {
return context?.chatTypeLabel?.trim() || '';
}
function normalizeChatContextIdList(ids?: string[] | null) {
if (!Array.isArray(ids)) {
return [];
}
return Array.from(
new Set(
ids
.map((id) => id?.trim() || '')
.filter(Boolean),
),
);
}
function normalizeChatContextEntries(
entries: ChatContext['defaultContexts'],
): Array<{ id: string; title: string; content: string }> {
if (!Array.isArray(entries)) {
return [];
}
return entries
.map((entry) => ({
id: entry?.id?.trim() || '',
title: entry?.title?.trim() || '',
content: entry?.content?.trim() || '',
}))
.filter((entry) => entry.content);
}
function buildStructuredChatContextSections(context: ChatContext | null) {
const sections: string[] = [];
const baseDescription = context?.chatTypeBaseDescription?.trim() || '';
const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
const customContextTitle = context?.customContextTitle?.trim() || '';
const customContextContent = context?.customContextContent?.trim() || '';
if (baseDescription) {
sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n'));
}
if (defaultContexts.length > 0) {
sections.push(
[
'## 채팅방에서 선택한 공통 문맥',
...defaultContexts.map((entry) =>
[`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'),
),
].join('\n\n'),
);
}
if (customContextTitle || customContextContent) {
sections.push(
[`## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`, customContextContent]
.filter(Boolean)
.join('\n'),
);
}
return sections;
}
function buildChatSessionReferenceContextSummary(context: ChatContext | null) {
const lines: string[] = [];
const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
const customContextTitle = context?.customContextTitle?.trim() || '';
const customContextContent = context?.customContextContent?.trim() || '';
lines.push('## 현재 채팅 유형 context 요약');
lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.');
lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.');
if (defaultContexts.length > 0) {
lines.push('');
lines.push('### 적용 중인 공통 문맥');
lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`));
}
if (customContextTitle || customContextContent) {
lines.push('');
lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`);
lines.push(customContextContent || '- 전용 메모 본문 없음');
}
return lines.join('\n');
}
function composeResolvedChatTypeDescription(
baseDescription: string,
defaultContexts: Array<{ id: string; title: string; content: string }>,
customContextTitle?: string | null,
customContextContent?: string | null,
) {
const sections = [baseDescription.trim()].filter(Boolean);
defaultContexts.forEach((context) => {
const normalizedContent = context.content.trim();
if (!normalizedContent) {
return;
}
sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`);
});
const normalizedCustomContextTitle = customContextTitle?.trim() || '';
const normalizedCustomContextContent = customContextContent?.trim() || '';
if (normalizedCustomContextTitle || normalizedCustomContextContent) {
sections.push(
[`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent]
.filter(Boolean)
.join('\n'),
);
}
return sections.join('\n\n').trim();
}
export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) {
if (!context) {
return null;
}
const normalizedChatTypeId = context.chatTypeId?.trim() || null;
if (!normalizedChatTypeId) {
return cloneChatContext(context);
}
const [chatTypesConfig, chatContextSettings] = await Promise.all([
getChatTypesConfig(),
getChatContextSettingsConfig(),
]);
const resolvedChatType =
chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null;
const roomContext =
chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null;
const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds);
const chatTypeDefaultContextIds =
chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? [];
const resolvedDefaultContextIds =
explicitDefaultContextIds.length > 0
? explicitDefaultContextIds
: roomContext?.defaultContextIds.length
? roomContext.defaultContextIds
: chatTypeDefaultContextIds;
const resolvedDefaultContexts = resolvedDefaultContextIds
.map((contextId) =>
chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled),
)
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.map((item) => ({
id: item.id,
title: item.title,
content: item.content,
}));
const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || '';
const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || '';
const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || '';
const resolvedDescription =
composeResolvedChatTypeDescription(
resolvedBaseDescription,
resolvedDefaultContexts,
resolvedCustomContextTitle,
resolvedCustomContextContent,
) ||
context.chatTypeDescription?.trim() ||
resolvedBaseDescription;
return {
...context,
chatTypeId: normalizedChatTypeId,
chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '',
chatTypeDescription: resolvedDescription,
chatTypeBaseDescription: resolvedBaseDescription,
defaultContextIds: resolvedDefaultContextIds,
defaultContexts: resolvedDefaultContexts,
customContextTitle: resolvedCustomContextTitle || null,
customContextContent: resolvedCustomContextContent || null,
} satisfies ChatContext;
}
function buildChatSessionReferenceAutoSection(args: {
context: ChatContext | null;
sessionId: string;
requestId: string;
requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
requestedAt?: Date | null;
completedAt?: Date | null;
input?: string;
}) {
const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음';
const pageTitle = args.context?.pageTitle?.trim() || '없음';
const topMenu = args.context?.topMenu?.trim() || '없음';
const pageUrl = args.context?.pageUrl?.trim() || '없음';
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
const requestStatusLabel =
args.requestStatus === 'completed'
? '완료'
: args.requestStatus === 'failed'
? '실패'
: args.requestStatus === 'cancelled'
? '취소'
: '실행 중';
return [
CHAT_SESSION_REFERENCE_AUTO_START,
'## 자동 갱신 문맥',
`- 마지막 갱신 시각: ${formatTime(new Date())}`,
`- sessionId: ${args.sessionId}`,
`- requestId: ${args.requestId}`,
`- 요청 상태: ${requestStatusLabel}`,
`- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`,
...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []),
`- 채팅 유형: ${chatTypeLabel}`,
`- 화면 제목: ${pageTitle}`,
`- topMenu: ${topMenu}`,
`- focusedComponentId: ${focusedComponentId}`,
`- pageUrl: ${pageUrl}`,
'',
buildChatSessionReferenceContextSummary(args.context),
CHAT_SESSION_REFERENCE_AUTO_END,
].join('\n');
}
function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) {
const trimmedExisting = existingContent.trim();
const defaultHeader = [
'# 채팅방 참고 리소스',
'',
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
].join('\n');
if (!trimmedExisting) {
return `${defaultHeader}\n\n${autoSection}\n`;
}
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
if (firstAutoStartIndex >= 0) {
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n`;
}
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
}
async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) {
try {
await writeFile(absolutePath, content, 'utf8');
return;
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error.code !== 'EACCES' && error.code !== 'EPERM')
) {
throw error;
}
}
await rm(absolutePath, { force: true });
await writeFile(absolutePath, content, 'utf8');
}
export async function ensureChatSessionReferenceResource(args: {
repoPath: string;
sessionId: string;
requestId: string;
context: ChatContext | null;
requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
requestedAt?: Date | null;
completedAt?: Date | null;
input: string;
recentHistoryLines: string[];
omittedHistoryCount: number;
}) {
const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId);
const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md');
const autoSection = buildChatSessionReferenceAutoSection({
context: args.context,
sessionId: args.sessionId,
requestId: args.requestId,
requestStatus: args.requestStatus,
requestedAt: args.requestedAt,
completedAt: args.completedAt,
input: args.input,
});
let existingContent = '';
try {
existingContent = await readFile(absolutePath, 'utf8');
} catch {
existingContent = '';
}
const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection);
if (nextContent !== existingContent) {
await writeChatSessionReferenceContentSafely(absolutePath, nextContent);
}
return resourceRelativePath;
}
async function refreshChatSessionReferenceForRequest(args: {
sessionId: string;
requestId: string;
context: ChatContext | null;
input: string;
requestStatus: 'started' | 'completed' | 'failed' | 'cancelled';
requestedAt: Date;
completedAt?: Date | null;
}) {
const repoPath = resolveMainProjectRoot();
await ensureChatSessionReferenceResource({
repoPath,
sessionId: args.sessionId,
requestId: args.requestId,
context: args.context,
requestStatus: args.requestStatus,
requestedAt: args.requestedAt,
completedAt: args.completedAt ?? null,
input: args.input,
recentHistoryLines: [],
omittedHistoryCount: 0,
});
}
function buildChatTypeInstructionBlock(context: ChatContext | null) {
const chatTypeLabel = resolvePromptChatTypeLabel(context);
const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
const structuredSections = buildStructuredChatContextSections(context);
const hasSpecificChatType = Boolean(chatTypeLabel);
const hasContextDescription = Boolean(
structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'),
);
if (!hasSpecificChatType && !hasContextDescription) {
return [
'## 채팅 유형 context 필수 규칙',
'- 선택된 채팅 유형 context가 없습니다.',
'- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.',
];
}
return [
'## 채팅 유형 context 필수 규칙',
'- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.',
'- 사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.',
'- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.',
'- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.',
'- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.',
'',
'### 선택된 채팅 유형',
`- label: ${chatTypeLabel || '없음'}`,
'',
'### 반드시 지킬 context 원문',
structuredSections.length > 0
? structuredSections.join('\n\n')
: (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'),
];
}
function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) {
const normalizedContent = referenceContent?.trim() || '';
if (!normalizedContent) {
return [
'## 채팅방 참고 문서',
'- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.',
'- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.',
];
}
return [
'## 채팅방 참고 문서',
'- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.',
'- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.',
'',
normalizedContent,
];
}
export function buildAgenticCodexPrompt(
context: ChatContext | null,
input: string,
sessionId: string,
promptContext?: {
repoPath?: string;
recentHistoryLines?: string[];
omittedHistoryCount?: number;
sessionReferenceResourcePath?: string;
sessionReferenceContent?: string;
},
) {
const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot();
const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`;
const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`;
const sessionReferenceResourcePath =
promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
const recentHistoryLines = promptContext?.recentHistoryLines ?? [];
const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0);
return [
'당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.',
'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.',
`저장소 루트(main_project): ${repoPath}`,
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
'가능한 작업 범위:',
'- 로컬 소스 파일 읽기',
'- 필요 시 DB 직접 조회',
'- 필요 시 로컬 API 응답 확인',
'- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정',
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.',
`- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
`- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
`- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`,
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent),
'',
...buildChatTypeInstructionBlock(context),
'',
'응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/Codex Live/.../sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
'- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.',
'- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
`- topMenu: ${context?.topMenu ?? '없음'}`,
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
'',
'최근 대화 문맥(보조 참조):',
...(recentHistoryLines.length > 0
? [
...recentHistoryLines.map((line) => `- ${line}`),
...(omittedHistoryCount > 0
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
: []),
]
: ['- 참조할 최근 대화가 없습니다.']),
'',
'사용자 요청:',
input,
'',
'최종 답변만 출력하세요.',
].join('\n');
}
export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) {
const issues: string[] = [];
try {
const repoStat = await stat(repoPath);
if (!repoStat.isDirectory()) {
issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로가 디렉터리가 아닙니다: ${repoPath}`);
}
} catch {
issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로를 찾지 못했습니다: ${repoPath}`);
}
const runnerCandidates = buildCommandRunnerApiCandidates('/health');
const headers = new Headers();
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) {
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
}
let runnerReachable = false;
for (const candidate of runnerCandidates) {
try {
const response = await fetch(candidate, {
method: 'GET',
headers,
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
runnerReachable = true;
break;
}
} catch {
// try next candidate
}
}
if (!runnerReachable) {
issues.push(`SERVER_COMMAND_RUNNER_URL 경로로 Codex runner에 연결하지 못했습니다: ${env.SERVER_COMMAND_RUNNER_URL}`);
}
if (issues.length > 0) {
throw new Error(`채팅 실행 환경이 준비되지 않았습니다. ${issues.join(' / ')}`);
}
}
function normalizeRunnerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildCommandRunnerApiCandidates(requestPath: string) {
const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';
let parsedUrl: URL;
try {
parsedUrl = new URL(configuredHealthUrl);
} catch {
return [];
}
const hostVariants =
parsedUrl.hostname === 'host.docker.internal'
? ['host.docker.internal', '127.0.0.1', 'localhost']
: parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
: [parsedUrl.hostname];
const deduped: string[] = [];
for (const hostname of hostVariants) {
const candidate = new URL(parsedUrl.toString());
candidate.hostname = hostname;
candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
candidate.search = '';
candidate.hash = '';
const serialized = normalizeRunnerUrl(candidate.toString());
if (!deduped.includes(serialized)) {
deduped.push(serialized);
}
}
return deduped;
}
async function requestCommandRunner(requestPath: string, init?: RequestInit) {
const headers = new Headers(init?.headers);
if (init?.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
}
let lastError: Error | null = null;
for (const url of buildCommandRunnerApiCandidates(requestPath)) {
try {
return await fetch(url, {
...init,
headers,
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
}
async function cancelRunnerCodexExecution(requestId: string) {
try {
const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, {
method: 'POST',
});
if (!response.ok) {
return false;
}
const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null;
return payload?.cancelled === true;
} catch {
return false;
}
}
async function runAgenticCodexReply(
context: ChatContext | null,
input: string,
sessionId: string,
requestId: string,
options?: {
omitPromptHistory?: boolean;
requestedAt?: Date | null;
},
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
const repoPath = resolveMainProjectRoot();
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const resolvedContext = await resolveCodexLiveChatContext(context, sessionId);
const appConfig = await getAppConfigSnapshot();
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
maxMessages: appConfig.chat?.maxContextMessages,
maxChars: appConfig.chat?.maxContextChars,
disabled: options?.omitPromptHistory === true,
});
const codexLiveMaxExecutionSeconds =
typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' &&
Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds)
? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds)))
: null;
const codexLiveIdleTimeoutSeconds =
typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' &&
Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds)
? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds)))
: null;
const requestedAt = options?.requestedAt ?? new Date();
const syncSessionReference = async (
requestStatus: 'started' | 'completed' | 'failed' | 'cancelled',
completedAt?: Date | null,
) =>
ensureChatSessionReferenceResource({
repoPath,
sessionId,
requestId,
context: resolvedContext,
requestStatus,
requestedAt,
completedAt: completedAt ?? null,
input,
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
});
const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({
repoPath,
sessionId,
requestId,
context: resolvedContext,
requestStatus: 'started',
requestedAt,
input,
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
});
const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath);
let sessionReferenceContent = '';
try {
sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8');
} catch {
sessionReferenceContent = '';
}
const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, {
repoPath,
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
sessionReferenceResourcePath,
sessionReferenceContent,
});
let streamedOutput = '';
let stdoutTail = '';
let stderr = '';
let jsonLineBuffer = '';
let lastProgressText = '';
let completedAgentMessage = '';
let hasIncrementalDelta = false;
const finalizeReplyOutput = async () => {
const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
if (!rewrittenOutput) {
return '';
}
// If the CLI only produced a final completed event, avoid sending it as one big batch.
if (!hasIncrementalDelta && rewrittenOutput) {
await streamReplyChunks(rewrittenOutput, onProgress);
} else if (rewrittenOutput !== lastProgressText) {
onProgress?.(rewrittenOutput);
}
return rewrittenOutput;
};
const throwIfCancelled = async () => {
if (!isCancellationRequested?.()) {
return;
}
await cancelRunnerCodexExecution(requestId).catch(() => false);
throw new Error('CHAT_RUNTIME_CANCELLED');
};
await throwIfCancelled();
activeChatProcessRegistry.set(requestId, {
cancel: async () => {
const cancelled = await cancelRunnerCodexExecution(requestId);
return cancelled || isCancellationRequested?.() === true;
},
});
chatRuntimeService.appendLog(
requestId,
`실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}`,
);
onActivity?.(
`# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}`,
);
try {
await new Promise<void>(async (resolve, reject) => {
const emitProgress = (nextText: string) => {
const normalizedProgress = nextText.trim();
if (!normalizedProgress || normalizedProgress === lastProgressText) {
return;
}
lastProgressText = normalizedProgress;
streamedOutput = normalizedProgress;
onProgress?.(normalizedProgress);
};
try {
await throwIfCancelled();
const response = await requestCommandRunner('/api/codex-live/execute', {
method: 'POST',
body: JSON.stringify({
requestId,
sessionId,
repoPath,
prompt,
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
maxExecutionSeconds: codexLiveMaxExecutionSeconds,
idleTimeoutSeconds: codexLiveIdleTimeoutSeconds,
}),
});
if (!response.ok) {
reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.'));
return;
}
await throwIfCancelled();
if (!response.body) {
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
return;
}
chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let remoteErrorMessage = '';
const handleRunnerLine = (line: string) => {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
return;
}
const eventType = typeof parsed.type === 'string' ? parsed.type : '';
if (eventType === 'started') {
const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null;
const appliedIdleTimeoutSeconds =
typeof parsed.configuredIdleTimeoutSeconds === 'number' &&
Number.isFinite(parsed.configuredIdleTimeoutSeconds)
? Math.round(parsed.configuredIdleTimeoutSeconds)
: null;
const appliedMaxExecutionSeconds =
typeof parsed.configuredMaxExecutionSeconds === 'number' &&
Number.isFinite(parsed.configuredMaxExecutionSeconds)
? Math.round(parsed.configuredMaxExecutionSeconds)
: null;
chatRuntimeService.attachProcess(requestId, pid);
chatRuntimeService.appendLog(
requestId,
pid
? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}`
: '호스트 command-runner에서 Codex 프로세스를 시작했습니다.',
);
if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) {
const appliedSummary =
`command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
`무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}`;
chatRuntimeService.appendLog(requestId, appliedSummary);
onActivity?.(`# ${appliedSummary}`);
if (
(appliedMaxExecutionSeconds != null &&
codexLiveMaxExecutionSeconds != null &&
appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) ||
(appliedIdleTimeoutSeconds != null &&
codexLiveIdleTimeoutSeconds != null &&
appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds)
) {
const mismatchSummary =
`설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` +
`무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` +
`실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
`무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}`;
chatRuntimeService.appendLog(requestId, mismatchSummary);
onActivity?.(`# 경고: ${mismatchSummary}`);
}
}
return;
}
if (eventType === 'attached') {
const attachedRequestId = String(parsed.requestId ?? '').trim();
const completed = parsed.completed === true;
const attachSummary =
attachedRequestId && attachedRequestId === requestId
? completed
? '기존 command-runner 실행 이력을 다시 연결했습니다.'
: '기존 command-runner 실행에 재부착했습니다.'
: completed
? '기존 command-runner 응답 이력을 다시 연결했습니다.'
: '기존 command-runner 실행 스트림에 다시 연결했습니다.';
chatRuntimeService.appendLog(requestId, attachSummary);
onActivity?.(`# ${attachSummary}`);
return;
}
if (eventType === 'activity') {
const activityLog = String(parsed.line ?? '').trim();
if (activityLog) {
chatRuntimeService.appendLog(requestId, activityLog);
onActivity?.(activityLog);
}
return;
}
if (eventType === 'delta') {
const deltaText = String(parsed.text ?? '');
if (deltaText) {
hasIncrementalDelta = true;
emitProgress(`${streamedOutput}${deltaText}`);
}
return;
}
if (eventType === 'completed') {
completedAgentMessage = String(parsed.text ?? '').trim();
if (completedAgentMessage && hasIncrementalDelta) {
emitProgress(completedAgentMessage);
}
return;
}
if (eventType === 'stdout') {
const stdoutLine = String(parsed.line ?? '').trim();
if (stdoutLine) {
const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine);
if (!structuredStdout.shouldKeepRaw) {
if (structuredStdout.activityLog) {
chatRuntimeService.appendLog(requestId, structuredStdout.activityLog);
onActivity?.(structuredStdout.activityLog);
}
if (structuredStdout.deltaText) {
hasIncrementalDelta = true;
emitProgress(`${streamedOutput}${structuredStdout.deltaText}`);
}
if (structuredStdout.completedText) {
completedAgentMessage = structuredStdout.completedText.trim();
if (completedAgentMessage && hasIncrementalDelta) {
emitProgress(completedAgentMessage);
}
}
return;
}
stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT);
chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`);
onActivity?.(`[stdout] ${stdoutLine}`);
}
return;
}
if (eventType === 'stderr') {
const stderrLine = String(parsed.line ?? '').trim();
if (stderrLine) {
stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT);
chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`);
onActivity?.(`[stderr] ${stderrLine}`);
}
return;
}
if (eventType === 'error') {
remoteErrorMessage = String(parsed.message ?? '').trim();
}
};
while (true) {
await throwIfCancelled();
const { value, done } = await reader.read();
if (done) {
break;
}
jsonLineBuffer += decoder.decode(value, { stream: true });
const lines = jsonLineBuffer.split('\n');
jsonLineBuffer = lines.pop() ?? '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
handleRunnerLine(line);
}
}
const trailingLine = jsonLineBuffer.trim();
if (trailingLine) {
handleRunnerLine(trailingLine);
}
if (remoteErrorMessage) {
reject(new Error(remoteErrorMessage));
return;
}
resolve();
} catch (error) {
reject(error);
}
});
} catch (error) {
const completedAt = new Date();
await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt);
const failureResponseText = await finalizeReplyOutput();
if (failureResponseText) {
throw new ChatRuntimeExecutionError(error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText);
}
throw error;
}
await syncSessionReference('completed', new Date());
return await finalizeReplyOutput();
}
async function getTodayAutomationRegistrationCounts() {
const [planCountResult, boardReceivedCountResult] = await Promise.all([
db(PLAN_TABLE)
.whereNot('automation_type', 'none')
.whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE])
.count<{ count: string }>('* as count')
.first(),
db(BOARD_POSTS_TABLE)
.whereNotNull('automation_received_at')
.whereRaw(
"(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date",
[KST_TIME_ZONE, KST_TIME_ZONE],
)
.count<{ count: string }>('* as count')
.first(),
]);
return {
today: formatKstDate(),
planCount: Number(planCountResult?.count ?? 0),
boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0),
};
}
async function buildAutomationRegistrationCountReply() {
const counts = await getTodayAutomationRegistrationCounts();
const lines = [
'결과',
`- ${counts.today} KST 기준 오늘 자동화 등록 총 건수는 ${counts.planCount}건입니다.`,
'- 기본 기준: `plan_items`에서 `automation_type <> \'none\'` 이고 오늘 생성된 건수',
];
if (counts.planCount === counts.boardReceivedCount) {
lines.push(`- 교차 확인: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수도 ${counts.boardReceivedCount}건으로 같습니다.`);
} else {
lines.push(`- 참고: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수는 ${counts.boardReceivedCount}건입니다.`);
lines.push('- 이 프로젝트는 게시판 등록, 자동화 접수, Plan 생성이 분리될 수 있어 기준에 따라 숫자가 달라질 수 있습니다.');
}
return lines.join('\n');
}
function buildAutomationRegistrationDefinitionReply() {
return [
'결과',
'- 이 프로젝트에서 `자동화 등록`은 하나로 고정된 용어가 아닙니다.',
'- `board_posts.created_at`: 게시판 등록',
'- `board_posts.automation_received_at`: 자동화 접수',
'- `plan_items.created_at` + `automation_type <> \'none\'`: 실제 자동화 대상 Plan 생성',
'- 건수 질문이면 어떤 기준인지 먼저 구분하고, 모호하면 기본적으로 Plan 생성 기준을 우선 안내합니다.',
].join('\n');
}
function buildProgressMessages(input: string) {
const messages = ['요청을 분석하고 있습니다.'];
if (isAutomationRegistrationCountRequest(input)) {
messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.');
return messages;
}
if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) {
messages.push('DB와 집계 기준을 확인하고 있습니다.');
}
if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) {
messages.push('API 경로와 실제 응답을 확인하고 있습니다.');
}
if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) {
messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.');
}
return [...new Set(messages)];
}
function normalizeProgressSummary(text: string) {
return text.replace(/\s+/g, ' ').trim();
}
function shouldSkipActivityProgressSummary(text: string) {
return (
!text ||
/^요청을 처리합니다\./.test(text) ||
/^완료(?:\(\d+\))?$/i.test(text) ||
/^종료\(\d+\)$/i.test(text)
);
}
export function summarizeActivityProgressLine(activityLine: string) {
const lines = String(activityLine ?? '')
.split('\n')
.map((line) => normalizeProgressSummary(line))
.filter(Boolean);
for (const line of lines) {
if (line.startsWith('# 이유:')) {
const summary = normalizeProgressSummary(line.slice('# 이유:'.length));
if (!shouldSkipActivityProgressSummary(summary)) {
return summary;
}
}
}
for (const line of lines) {
for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) {
if (!line.startsWith(prefix)) {
continue;
}
const summary = normalizeProgressSummary(line.slice(prefix.length));
if (!shouldSkipActivityProgressSummary(summary)) {
return summary;
}
}
}
return '';
}
function stripActivityLinePrefix(line: string) {
return line.replace(/^#\s*(||||):\s*/u, '').trim();
}
function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) {
if (!summary) {
return null;
}
if (/^#\s*:/u.test(normalizedLine) || /||/u.test(summary)) {
return 5;
}
if (/ || /u.test(summary)) {
return 1;
}
if (/||| /u.test(summary)) {
return 2;
}
if (/\bdb\b||\bapi\b|||||||||resource||/u.test(summary)) {
return 3;
}
if (/|||||patch|diff| /u.test(summary)) {
return 4;
}
if (/|||preview|||/u.test(summary)) {
return 5;
}
return null;
}
function createCompactActivityLogEntry(activityLine: string) {
const normalizedLine = normalizeProgressSummary(activityLine);
if (!normalizedLine) {
return null;
}
const strippedSummary = stripActivityLinePrefix(normalizedLine);
const summarizedProgress = summarizeActivityProgressLine(normalizedLine);
const summary = summarizedProgress || strippedSummary;
const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine);
if (!lineNo || !summary) {
return null;
}
if (/^#\s*:/u.test(normalizedLine)) {
return {
line: `# 오류: ${summary}`,
lineNo,
};
}
const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:';
return {
line: `${prefix} ${summary}`,
lineNo,
};
}
function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
const normalized = input.toLowerCase();
const pageTitle = context?.pageTitle ?? '현재 화면';
const previewUrl = context?.pageUrl || '없음';
const detailRequested = isDetailRequest(input);
if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
const lines = [
'결과',
`- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`,
];
if (snapshot?.latestSourceWork?.summary) {
lines.push(`- 요약: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
}
if (detailRequested && snapshot?.latestSourceWork) {
lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
}
if (!detailRequested) {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
if (input.includes('계획') || normalized.includes('plan')) {
if (!snapshot) {
return ['결과', '- 연결된 Plan을 찾지 못했습니다.', '- planId 또는 작업 ID를 함께 보내 주세요.'].join('\n');
}
const lines = [
'작업 요약',
`- #${snapshot.planId} ${snapshot.workId}`,
`- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
`- 메모: ${truncateText(snapshot.note, 110) || '기록 없음'}`,
];
if (detailRequested) {
if (snapshot.assignedBranch) {
lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
}
lines.push(...buildRecentPlanHistoryLines(snapshot));
} else {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
if ((input.includes('이슈') || normalized.includes('issue')) && snapshot) {
const lines = ['이슈 요약'];
if (snapshot.latestIssue) {
lines.push(
`- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`,
`- 내용: ${truncateText(snapshot.latestIssue.message, 110)}`,
);
} else if (snapshot.issueTags.length > 0) {
lines.push(`- 태그: ${snapshot.issueTags.join(', ')}`);
} else {
lines.push('- 최근 이슈 이력이 없습니다.');
}
if (detailRequested) {
lines.push(...buildRecentPlanHistoryLines(snapshot));
}
return lines.join('\n');
}
if ((input.includes('이력') || input.includes('최근') || normalized.includes('history')) && snapshot) {
const lines = [
'최근 이력',
`- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '최근 작업 이력이 없습니다.'}`,
];
if (detailRequested) {
lines.push(...buildRecentPlanHistoryLines(snapshot));
} else {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
if (input.includes('문서') || normalized.includes('docs')) {
return ['결과', '- 문서 요청으로 인식했습니다.', '- 대상 문서명이나 링크를 함께 보내 주세요.'].join('\n');
}
if (isWorklogRequest(input)) {
if (snapshot) {
return buildWorklogReply(context, snapshot);
}
return ['결과', '- 작업로그 초안을 만들 Plan이 없습니다.', '- planId 또는 작업 화면에서 다시 요청해 주세요.'].join('\n');
}
if (
input.includes('컴포넌트') ||
normalized.includes('widget') ||
normalized.includes('api')
) {
return ['결과', '- 대상 리소스를 특정해 주세요.', '- 예: 버튼 컴포넌트, previewer, history api'].join('\n');
}
if (snapshot && isPlanDetailRequest(input)) {
const lines = [
'작업 요약',
`- #${snapshot.planId} ${snapshot.workId}`,
`- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
`- 최근 작업: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '기록 없음'}`,
];
if (detailRequested) {
lines.push(...buildRecentPlanHistoryLines(snapshot));
} else {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
return [
'결과',
`- 현재 화면: ${pageTitle}`,
'- 요청을 더 구체적으로 적어 주시면 필요한 정보만 짧게 정리합니다.',
'- 예: preview 링크, 최근 이슈, 작업 요약, 상세',
].join('\n');
}
function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
const normalized = input.toLowerCase();
const detailRequested = isDetailRequest(input);
const pageTitle = context?.pageTitle ?? '현재 화면';
if (!snapshot) {
return `현재 화면: ${pageTitle}\n선택된 Plan을 찾지 못했습니다. Plan 목록에서 항목을 선택한 뒤 다시 요청해 주세요.`;
}
const lines = ['작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`];
if (isWorklogRequest(input)) {
return buildWorklogReply(context, snapshot);
}
if (snapshot.note) {
lines.push(`- 메모: ${truncateText(snapshot.note, 110)}`);
}
if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '없음'}`);
if (snapshot.latestSourceWork) {
lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
if (detailRequested) {
lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
}
}
if (!detailRequested) {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
if (snapshot.latestIssue) {
lines.push(`- 최근 이슈: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`);
if (detailRequested) {
lines.push(`- 이슈 내용: ${truncateText(snapshot.latestIssue.message, 100)}`);
}
} else if (snapshot.issueTags.length > 0) {
lines.push(`- 이슈 태그: ${snapshot.issueTags.join(', ')}`);
}
if (snapshot.latestActionNote) {
lines.push(`- 최근 조치: ${truncateText(snapshot.latestActionNote, 100)}`);
}
if (snapshot.latestSourceWork) {
lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) {
lines.push(`- 파일 ${snapshot.latestSourceWork.changedFiles.length}개 변경`);
}
}
if (snapshot.lastError && snapshot.hasOpenIssues) {
lines.push(`- 주의: ${truncateText(snapshot.lastError, 100)}`);
}
if (detailRequested) {
if (snapshot.assignedBranch) {
lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
}
lines.push(...buildRecentPlanHistoryLines(snapshot));
} else {
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
}
return lines.join('\n');
}
async function buildCodexReply(
context: ChatContext | null,
input: string,
sessionId: string,
requestId: string,
options?: {
omitPromptHistory?: boolean;
requestedAt?: Date | null;
},
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
return runAgenticCodexReply(
context,
input,
sessionId,
requestId,
options,
onProgress,
onActivity,
isCancellationRequested,
);
}
export class ChatService {
private readonly wss = new WebSocketServer({ noServer: true });
private readonly clientStates = new Map<WebSocket, ChatSessionState>();
private readonly sessions = new Map<string, ChatSessionState>();
private readonly cancelledRequestIds = new Set<string>();
private readonly unsubscribeRuntimeBroadcast: () => void;
private readonly unsubscribeNotificationBroadcast: () => void;
constructor(private readonly logger: FastifyBaseLogger) {
activeChatService = this;
activeRuntimeController = {
getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId),
};
this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => {
this.broadcastRuntimeSnapshot(snapshot);
});
this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => {
this.broadcastNotificationMessageChange(event);
});
this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => {
void this.handleConnection(socket, request).catch((error: unknown) => {
const session = this.clientStates.get(socket);
this.logger.error(error, 'chat websocket connection initialization failed');
this.clientStates.delete(socket);
if (session?.socket === socket) {
session.socket = null;
}
closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error');
});
});
}
attachUpgradeHandler() {
return (request: IncomingMessage, socket: Socket, head: Buffer) => {
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
const url = new URL(request.url ?? '/', origin);
if (url.pathname !== SOCKET_PATH) {
socket.destroy();
return;
}
this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => {
this.wss.emit('connection', websocket, request);
});
};
}
close() {
activeRuntimeController = null;
if (activeChatService === this) {
activeChatService = null;
}
this.unsubscribeRuntimeBroadcast();
this.unsubscribeNotificationBroadcast();
for (const execution of activeChatProcessRegistry.values()) {
void Promise.resolve(execution.cancel()).catch(() => {
// noop
});
}
activeChatProcessRegistry.clear();
chatRuntimeService.clearAll();
for (const client of this.clientStates.keys()) {
client.close();
}
this.wss.close();
}
async recoverInterruptedSessions() {
const recoverableRequests = await listRecoverableChatConversationRequests();
if (recoverableRequests.length === 0) {
return {
sessionCount: 0,
restartedCount: 0,
requeuedCount: 0,
};
}
const requestsBySession = new Map<string, typeof recoverableRequests>();
for (const item of recoverableRequests) {
const existing = requestsBySession.get(item.sessionId);
if (existing) {
existing.push(item);
} else {
requestsBySession.set(item.sessionId, [item]);
}
}
let restartedCount = 0;
let requeuedCount = 0;
for (const [sessionId, items] of requestsBySession.entries()) {
const session = this.getOrCreateSession(sessionId, items[0]?.clientId ?? null);
const primaryItem =
items.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ??
items.find((item) => item.status === 'started') ??
items[0];
session.clientId = primaryItem?.clientId?.trim() || session.clientId;
session.context = {
pageId: null,
pageTitle: '',
topMenu: '',
focusedComponentId: null,
pageUrl: '',
chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null,
chatTypeLabel: primaryItem?.contextLabel ?? '',
chatTypeDescription: primaryItem?.contextDescription ?? '',
};
for (const item of items) {
if (item.requestId === primaryItem?.requestId) {
continue;
}
const requestedAtMs = parseRequestedAtMs(item.createdAt);
session.queue.push({
requestId: item.requestId,
text: item.userText,
mode: 'queue',
requestedAtMs,
context: session.context ? cloneChatContext(session.context) : null,
});
chatRuntimeService.enqueueJob({
sessionId,
requestId: item.requestId,
mode: 'queue',
text: item.userText,
});
chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.');
requeuedCount += 1;
}
if (!primaryItem) {
continue;
}
restartedCount += 1;
void this.executeRequest(session, {
requestId: primaryItem.requestId,
text: primaryItem.userText,
mode: 'queue',
requestedAtMs: parseRequestedAtMs(primaryItem.createdAt),
context: session.context ? cloneChatContext(session.context) : null,
}).catch((error: unknown) => {
this.logger.error(
{ error, sessionId: session.sessionId, requestId: primaryItem.requestId },
'failed to recover interrupted chat request',
);
});
}
this.logger.info(
{
sessionCount: requestsBySession.size,
restartedCount,
requeuedCount,
},
'recovered interrupted chat sessions after work-server restart',
);
return {
sessionCount: requestsBySession.size,
restartedCount,
requeuedCount,
};
}
private getOrCreateSession(sessionId: string, clientId?: string | null) {
const existing = this.sessions.get(sessionId);
if (existing) {
if (clientId?.trim()) {
existing.clientId = clientId.trim();
}
return existing;
}
const nextSession: ChatSessionState = {
sessionId,
clientId: clientId?.trim() || null,
socket: null,
lastSeenAt: Date.now(),
isDeleted: false,
context: null,
queue: [],
activeRequestCount: 0,
pendingQueueReleaseEventId: null,
nextEventId: 1,
eventHistory: [],
messagePersistenceTail: Promise.resolve(),
watchedRuntimeRequestId: null,
};
this.sessions.set(sessionId, nextSession);
return nextSession;
}
private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage {
return {
...message,
eventId: session.nextEventId++,
sessionId: session.sessionId,
};
}
private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) {
if (envelope.type === 'chat:init' || envelope.type === 'chat:status') {
return;
}
session.eventHistory.push(envelope);
if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) {
session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT);
}
}
private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
if (session.isDeleted) {
return Promise.resolve();
}
const nextPersistence = session.messagePersistenceTail
.catch(() => undefined)
.then(() =>
appendChatConversationMessage(
{
sessionId: session.sessionId,
clientId: session.clientId,
title: null,
contextLabel: session.context?.chatTypeLabel ?? null,
contextDescription: session.context?.chatTypeDescription ?? null,
notifyOffline: undefined,
},
{
sessionId: session.sessionId,
messageId: message.id,
author: message.author,
text: message.text,
timestamp: message.timestamp,
clientRequestId: message.clientRequestId ?? null,
parts: message.parts ?? [],
},
),
);
session.messagePersistenceTail = nextPersistence.catch((error: unknown) => {
this.logger.error(error, 'failed to persist chat message');
});
return session.messagePersistenceTail;
}
private sendToSession(
session: ChatSessionState,
message: ChatOutboundPayload,
options?: {
skipOfflineNotification?: boolean;
},
) {
if (session.isDeleted) {
const normalizedDeletedMessage =
message.type === 'chat:message'
? {
...message,
payload: normalizeStructuredChatMessage(message.payload),
}
: message;
return this.createSessionEnvelope(session, normalizedDeletedMessage);
}
const normalizedMessage =
message.type === 'chat:message'
? {
...message,
payload: normalizeStructuredChatMessage(message.payload),
}
: message;
const envelope = this.createSessionEnvelope(session, normalizedMessage);
this.retainEnvelopeForReplay(session, envelope);
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
if (normalizedMessage.type === 'chat:message') {
this.persistConversationMessage(session, normalizedMessage.payload);
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload);
}
}
return envelope;
}
private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) {
try {
await this.sendOfflineNotificationIfNeeded(session, message);
} catch (error: unknown) {
this.logger.error(error, 'failed to send offline chat notification; request processing will continue');
}
}
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
const normalizedMessage = normalizeStructuredChatMessage(message);
const envelope = this.createSessionEnvelope(session, {
type: 'chat:message:update',
payload: normalizedMessage,
});
this.retainEnvelopeForReplay(session, envelope);
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket message update envelope');
// Streaming codex deltas and synthesized activity summaries are transient UI state.
// Persist only the final chat message / activity rows to avoid long DB tails that
// can keep a finished request looking "running" until every intermediate update flushes.
if (shouldPersistMessageUpdate(normalizedMessage)) {
this.persistConversationMessage(session, normalizedMessage);
}
return envelope;
}
private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) {
for (const session of this.sessions.values()) {
this.sendToSession(session, {
type: 'chat:runtime',
payload: snapshot,
});
this.pushRuntimeDetail(session);
}
}
private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) {
for (const session of this.sessions.values()) {
const payload =
event.action === 'deleted'
? {
action: 'deleted' as const,
itemId: event.id,
}
: {
action: event.action,
itemId: event.item.id,
category: event.item.category,
read: event.item.read,
};
const envelope = this.createSessionEnvelope(session, {
type: 'notification:messages-updated',
payload,
});
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send notification message update envelope');
}
}
getSessionClientIdMap() {
return new Map(
[...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]),
);
}
private pushRuntimeDetail(session: ChatSessionState) {
const requestId = session.watchedRuntimeRequestId?.trim();
if (!requestId) {
return;
}
this.sendToSession(session, {
type: 'chat:runtime:detail',
payload: chatRuntimeService.getJobDetail(requestId),
});
}
private async cancelRuntimeJob(requestId: string) {
const execution = activeChatProcessRegistry.get(requestId);
const detail = chatRuntimeService.getJobDetail(requestId);
if (!execution && detail.item && detail.terminalStatus == null) {
chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
this.cancelledRequestIds.add(requestId);
const session = this.findSessionByRequestId(requestId);
if (session) {
void upsertChatConversationRequest(session.sessionId, {
requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
}).catch((error: unknown) => {
this.logger.warn(error, 'failed to persist pending chat request cancellation state');
});
}
return true;
}
if (!execution) {
return false;
}
chatRuntimeService.appendLog(requestId, '사용자 요청으로 실행 취소를 시도합니다.');
this.cancelledRequestIds.add(requestId);
const session = this.findSessionByRequestId(requestId);
if (session) {
void upsertChatConversationRequest(session.sessionId, {
requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 실행 취소를 시도합니다.',
}).catch((error: unknown) => {
this.logger.warn(error, 'failed to persist chat request cancellation state');
});
}
try {
const cancelled = await execution.cancel();
if (!cancelled && this.cancelledRequestIds.has(requestId)) {
chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
return true;
}
return cancelled;
} catch (error) {
this.logger.warn(error, 'failed to cancel chat runtime job');
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
this.cancelledRequestIds.delete(requestId);
return false;
}
}
private async removeQueuedRuntimeJob(requestId: string) {
let removed = false;
let removedSessionId: string | null = null;
for (const session of this.sessions.values()) {
const nextQueue = session.queue.filter((item) => item.requestId !== requestId);
if (nextQueue.length === session.queue.length) {
continue;
}
session.queue = nextQueue;
removed = true;
removedSessionId = session.sessionId;
this.emitJobState(session, {
requestId,
status: 'completed',
mode: 'queue',
queueSize: session.queue.length,
message: '대기열에서 제거됨',
});
this.sendToSession(session, {
type: 'chat:message',
payload: createMessage('system', '대기열 요청이 관리 화면에서 제거되었습니다.'),
});
break;
}
if (!removed) {
return false;
}
chatRuntimeService.finishJob(requestId, 'removed');
if (removedSessionId) {
await upsertChatConversationRequest(removedSessionId, {
requestId,
status: 'removed',
statusMessage: '대기열에서 제거되었습니다.',
});
}
return true;
}
private tryStartNextQueuedRequest(session: ChatSessionState) {
this.normalizeSessionExecutionState(session);
if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) {
return;
}
const nextQueuedRequest = session.queue.shift();
if (!nextQueuedRequest) {
return;
}
void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => {
this.logger.error(error, 'queued chat reply build failed');
this.sendToSession(session, {
type: 'chat:error',
payload: {
message: '대기 중이던 채팅 요청 처리 중 오류가 발생했습니다.',
},
});
});
}
private normalizeSessionExecutionState(session: ChatSessionState) {
const snapshot = chatRuntimeService.getSnapshot();
const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null;
const runtimeRunningCount = runtimeSession?.runningCount ?? 0;
const runtimeQueuedIds = new Set(
snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId),
);
session.activeRequestCount = runtimeRunningCount;
session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId));
if (runtimeRunningCount === 0) {
session.pendingQueueReleaseEventId = null;
}
}
private emitJobState(
session: ChatSessionState,
payload: {
requestId: string;
status: 'queued' | 'started' | 'completed' | 'failed';
mode: 'queue' | 'direct';
queueSize: number;
message: string;
},
) {
this.sendToSession(session, {
type: 'chat:job',
payload,
});
void updateChatConversationJobState(session.sessionId, {
requestId: payload.requestId,
status: payload.status,
message: payload.message,
queueSize: payload.queueSize,
clear: false,
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist chat job state');
});
const requestStatus =
payload.status === 'queued'
? 'queued'
: payload.status === 'started'
? 'started'
: payload.status === 'completed'
? 'completed'
: this.cancelledRequestIds.has(payload.requestId)
? 'cancelled'
: 'failed';
void upsertChatConversationRequest(session.sessionId, {
requestId: payload.requestId,
status: requestStatus,
statusMessage: payload.message,
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist chat request state');
});
}
private findSessionByRequestId(requestId: string) {
for (const session of this.sessions.values()) {
if (session.queue.some((item) => item.requestId === requestId)) {
return session;
}
if (
session.eventHistory.some((event) => {
if (event.type === 'chat:job') {
return event.payload.requestId === requestId;
}
if (event.type === 'chat:message' || event.type === 'chat:message:update') {
return event.payload.clientRequestId === requestId;
}
return false;
})
) {
return session;
}
}
return null;
}
private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) {
if (!session.clientId?.trim()) {
return;
}
if (message.author !== 'codex' || isPreparingChatReply(message.text)) {
return;
}
const appOrigin = resolveChatContextAppOrigin(session.context);
const [conversation, appConfig] = await Promise.all([
getChatConversation(session.sessionId, session.clientId),
getAppConfigSnapshot(appOrigin),
]);
if (
!shouldSendOfflineChatNotification({
receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications,
conversationNotifyOffline: conversation?.notifyOffline,
})
) {
return;
}
const requestOwnerClientId = message.clientRequestId?.trim()
? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null))
?.requesterClientId?.trim() || null
: null;
if (!requestOwnerClientId) {
return;
}
const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, {
keepClientIds: [session.clientId, requestOwnerClientId],
}).catch(() => []);
const notificationCandidateClientIds = collectOfflineNotificationClientIds(session.clientId, [
requestOwnerClientId,
...preferredNotificationClientIds,
]);
const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => {
const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values());
logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation);
return !evaluation.isActive;
});
if (!notificationTargetClientIds.length) {
this.logger.info(
{
sessionId: session.sessionId,
messageId: message.id,
requestOwnerClientId,
notificationCandidateClientIds,
},
'chat offline notification skipped because every target client is actively viewing',
);
return;
}
const notificationPayload = await this.buildOfflineChatNotificationPayload(
session,
message,
conversation?.title || '현재 채팅방',
);
if (!notificationPayload) {
return;
}
await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds);
const result = await sendNotifications(
{
title: notificationPayload.title,
body: notificationPayload.body,
data: notificationPayload.data,
threadId: notificationPayload.threadId,
targetClientIds: notificationTargetClientIds,
},
{
disableIos: true,
},
);
if (!result.web.ok && !result.web.skipped) {
this.logger.warn(
{
sessionId: session.sessionId,
messageId: message.id,
targetClientIds: notificationTargetClientIds,
webPush: result.web,
},
'chat webpush delivery reported failures',
);
}
}
private async createOfflineAppNotificationIfNeeded(
payload: {
title: string;
body: string;
previewText: string;
metadata: Record<string, string>;
linkUrl: string;
},
targetClientIds: string[],
) {
await createNotificationMessage({
title: payload.title,
body: payload.body,
category: 'chat',
source: 'codex-live',
priority: 'normal',
metadata: {
...payload.metadata,
targetClientIds,
linkUrl: payload.linkUrl,
linkLabel: '채팅 바로 열기',
previewText: payload.previewText,
},
});
}
private async buildOfflineChatNotificationPayload(
session: ChatSessionState,
message: ChatMessage,
conversationTitle: string,
) {
const answerText = String(message.text ?? '').trim();
if (!answerText || isPreparingChatReply(answerText)) {
return null;
}
const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId);
const requests = message.clientRequestId?.trim()
? await listChatConversationRequests(session.sessionId, 200).catch(() => [])
: [];
const matchingRequest =
requests.find((request) => request.requestId === message.clientRequestId) ??
requests.find((request) => request.responseMessageId === message.id) ??
null;
const questionText = matchingRequest?.userText?.trim() || '';
const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} 채팅방에 새 응답이 도착했습니다.`;
const body = createChatQuestionAnswerNotificationBody({
questionText,
answerText,
fallback,
});
const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback);
if (!body.trim()) {
return null;
}
const metadata = {
category: 'chat',
sessionId: session.sessionId,
conversationTitle,
messageId: String(message.id),
messageTimestamp: message.timestamp,
questionText: normalizeNotificationDetailText(questionText) ?? '',
answerText: normalizeNotificationDetailText(answerText) ?? '',
targetUrl: linkUrl,
notificationKey: `chat:${session.sessionId}:${message.id}`,
type: 'chat-reply',
suppressIfVisible: 'true',
};
return {
title: 'Codex Live 새 메시지',
body,
previewText,
linkUrl,
threadId: `chat:${session.sessionId}`,
data: metadata,
metadata,
};
}
private replaySessionHistory(session: ChatSessionState, lastEventId: number) {
if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
return;
}
const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId);
for (const envelope of pendingEnvelopes) {
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to replay websocket session history envelope');
}
}
private async initializeSession(session: ChatSessionState) {
if (session.isDeleted) {
return;
}
await session.messagePersistenceTail.catch(() => undefined);
const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });
const initEnvelope = this.createSessionEnvelope(session, {
type: 'chat:init',
payload: {
messages,
},
});
sendSocketEnvelope(this.logger, session.socket, initEnvelope, 'failed to send websocket init envelope');
const statusEnvelope = this.createSessionEnvelope(session, {
type: 'chat:status',
payload: {
connectedAt: new Date().toISOString(),
},
});
sendSocketEnvelope(this.logger, session.socket, statusEnvelope, 'failed to send websocket status envelope');
this.sendToSession(session, {
type: 'chat:runtime',
payload: chatRuntimeService.getSnapshot(),
});
}
private async handleConnection(socket: WebSocket, request: IncomingMessage) {
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
const url = new URL(request.url ?? '/', origin);
if (!hasAuthorizedChatSocketAccess(request, url)) {
closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized');
return;
}
const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
const clientId = url.searchParams.get('clientId')?.trim() || null;
const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0;
const session = this.getOrCreateSession(requestedSessionId, clientId);
if (session.socket && session.socket !== socket) {
closeSocketSafely(this.logger, session.socket, 'failed to close superseded websocket session');
}
session.socket = socket;
session.lastSeenAt = Date.now();
this.clientStates.set(socket, session);
socket.on('message', (raw: RawData) => {
this.handleMessage(socket, raw);
});
socket.on('close', () => {
this.clientStates.delete(socket);
if (session.socket === socket) {
session.socket = null;
}
});
socket.on('error', (error: Error) => {
this.logger.error(error, 'chat websocket error');
this.clientStates.delete(socket);
if (session.socket === socket) {
session.socket = null;
}
});
await this.initializeSession(session);
this.replaySessionHistory(session, lastEventId);
}
async forgetSession(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const session = this.sessions.get(normalizedSessionId);
const runtimeSnapshot = chatRuntimeService.getSnapshot();
const runtimeRequestIds = new Set(
[...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
.filter((item) => item.sessionId === normalizedSessionId)
.map((item) => item.requestId),
);
if (session) {
session.isDeleted = true;
session.queue = [];
session.eventHistory = [];
session.pendingQueueReleaseEventId = null;
session.watchedRuntimeRequestId = null;
session.activeRequestCount = 0;
if (session.socket) {
this.clientStates.delete(session.socket);
closeSocketSafely(this.logger, session.socket, 'failed to close deleted chat websocket session');
session.socket = null;
}
this.sessions.delete(normalizedSessionId);
}
for (const requestId of runtimeRequestIds) {
const detail = chatRuntimeService.getJobDetail(requestId);
if (detail.availableActions.cancel) {
try {
await this.cancelRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
} else if (detail.availableActions.remove) {
try {
await this.removeQueuedRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
}
activeChatProcessRegistry.delete(requestId);
this.cancelledRequestIds.delete(requestId);
}
chatRuntimeService.clearSession(normalizedSessionId);
}
resetSessionData(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const session = this.sessions.get(normalizedSessionId);
if (!session) {
return;
}
session.queue = [];
session.eventHistory = [];
session.pendingQueueReleaseEventId = null;
session.watchedRuntimeRequestId = null;
session.activeRequestCount = 0;
}
private handleMessage(socket: WebSocket, raw: RawData) {
try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage;
const session = this.clientStates.get(socket);
if (session) {
session.lastSeenAt = Date.now();
}
if (message.type === 'context:update') {
this.handleContextUpdate(socket, message.payload);
return;
}
if (message.type === 'presence:ping') {
return;
}
if (message.type === 'event:received') {
const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0));
if (
session &&
Number.isFinite(receivedEventId) &&
receivedEventId > 0 &&
session.pendingQueueReleaseEventId !== null &&
receivedEventId >= session.pendingQueueReleaseEventId
) {
session.pendingQueueReleaseEventId = null;
this.tryStartNextQueuedRequest(session);
}
return;
}
if (message.type === 'message:send') {
void this.handleUserMessage(
socket,
message.payload.text,
message.payload.requestId,
message.payload.mode === 'direct' ? 'direct' : 'queue',
{
chatTypeId: message.payload.chatTypeId ?? null,
chatTypeLabel: message.payload.chatTypeLabel,
chatTypeDescription: message.payload.chatTypeDescription,
chatTypeBaseDescription: message.payload.chatTypeBaseDescription,
defaultContextIds: message.payload.defaultContextIds,
defaultContexts: message.payload.defaultContexts,
customContextTitle: message.payload.customContextTitle,
customContextContent: message.payload.customContextContent,
},
{
omitPromptHistory: message.payload.omitPromptHistory === true,
},
).catch((error: unknown) => {
this.logger.error(error, 'chat reply build failed');
const session = this.clientStates.get(socket);
if (!session) {
return;
}
this.sendToSession(session, {
type: 'chat:error',
payload: {
message:
error instanceof Error && error.message.trim()
? error.message.trim()
: '채팅 응답을 만드는 중 오류가 발생했습니다.',
},
});
});
return;
}
if (message.type === 'runtime:watch') {
if (!session) {
return;
}
session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null;
this.pushRuntimeDetail(session);
}
} catch (error) {
this.logger.warn(error, 'invalid chat websocket payload');
const session = this.clientStates.get(socket);
if (!session) {
return;
}
this.sendToSession(session, {
type: 'chat:error',
payload: {
message: '채팅 메시지를 처리하지 못했습니다.',
},
});
}
}
private handleContextUpdate(socket: WebSocket, context: ChatContext) {
const state = this.clientStates.get(socket);
if (!state) {
return;
}
const previousFocus = state.context?.focusedComponentId;
const previousContextLabel = state.context?.chatTypeLabel ?? null;
const previousContextDescription = state.context?.chatTypeDescription ?? null;
state.context = context;
const nextContextLabel = context.chatTypeLabel ?? null;
const nextContextDescription = context.chatTypeDescription ?? null;
if (previousContextLabel !== nextContextLabel || previousContextDescription !== nextContextDescription) {
void updateChatConversationContext(state.sessionId, {
clientId: state.clientId,
contextLabel: nextContextLabel,
contextDescription: nextContextDescription,
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist chat context');
});
}
if (context.focusedComponentId && context.focusedComponentId !== previousFocus) {
this.sendToSession(state, {
type: 'chat:message',
payload: createMessage('codex', `선택 포커스가 ${context.focusedComponentId}로 이동했습니다. 관련 문맥을 반영합니다.`),
});
}
}
private async handleUserMessage(
socket: WebSocket,
text: string,
requestId?: string,
mode: 'queue' | 'direct' = 'queue',
contextOverride?: Partial<ChatContext> | null,
requestOptions?: {
omitPromptHistory?: boolean;
},
) {
const trimmed = text.trim();
if (!trimmed) {
return;
}
const state = this.clientStates.get(socket);
if (!state) {
return;
}
if (contextOverride) {
const mergedContext = {
...(state.context ?? {
pageId: null,
pageTitle: '',
topMenu: '',
focusedComponentId: null,
pageUrl: '',
}),
...contextOverride,
};
state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext;
void updateChatConversationContext(state.sessionId, {
clientId: state.clientId,
chatTypeId: state.context.chatTypeId ?? null,
lastChatTypeId: state.context.chatTypeId ?? null,
contextLabel: state.context.chatTypeLabel ?? null,
contextDescription: state.context.chatTypeDescription ?? null,
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist chat context from message send');
});
}
this.normalizeSessionExecutionState(state);
const nextRequestId = requestId?.trim() || createRequestId();
const requestedAtMs = Date.now();
const request = {
requestId: nextRequestId,
text: trimmed,
mode,
requestedAtMs,
omitPromptHistory: requestOptions?.omitPromptHistory === true,
context: cloneChatContext(state.context),
};
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
const queuedUserMessage = {
...createMessage('user', trimmed, nextRequestId),
timestamp: formatTime(new Date(requestedAtMs)),
};
this.sendToSession(state, {
type: 'chat:message',
payload: queuedUserMessage,
});
state.queue.push(request);
chatRuntimeService.enqueueJob({
sessionId: state.sessionId,
requestId: nextRequestId,
mode,
text: trimmed,
});
chatRuntimeService.registerQueuedControl(nextRequestId, {
remove: () => this.removeQueuedRuntimeJob(nextRequestId),
});
this.emitJobState(state, {
requestId: nextRequestId,
status: 'queued',
mode,
queueSize: state.queue.length,
message: `대기열 ${state.queue.length}`,
});
void upsertChatConversationRequest(state.sessionId, {
requestId: nextRequestId,
status: 'queued',
statusMessage: `대기열 ${state.queue.length}`,
userMessageId: queuedUserMessage.id,
userText: trimmed,
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist queued chat request');
});
return;
}
await this.executeRequest(state, request);
}
private async executeRequest(
session: ChatSessionState,
request: {
requestId: string;
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
omitPromptHistory?: boolean;
context: ChatContext | null;
},
) {
let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
let hasAnnouncedStreaming = false;
const compactActivityLineMap = new Map<number, string>();
session.activeRequestCount += 1;
const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId);
const hasStoredUserMessage = existingRequest?.userMessageId != null;
if (!hasStoredUserMessage) {
this.sendToSession(session, {
type: 'chat:message',
payload: {
...createMessage('user', request.text, request.requestId),
timestamp: formatTime(new Date(request.requestedAtMs)),
},
});
}
const appendActivityLine = (line: string) => {
const compactEntry = createCompactActivityLogEntry(line);
if (!compactEntry) {
return;
}
const previousLine = compactActivityLineMap.get(compactEntry.lineNo);
if (previousLine === compactEntry.line) {
return;
}
compactActivityLineMap.set(compactEntry.lineNo, compactEntry.line);
const activityLines = Array.from(compactActivityLineMap.entries())
.sort((left, right) => left[0] - right[0])
.map(([, activityLine]) => activityLine);
void appendChatConversationActivityLine(
session.sessionId,
request.requestId,
compactEntry.line,
compactEntry.lineNo,
).catch((error: unknown) => {
this.logger.warn(error, 'failed to persist compact chat activity line');
});
this.sendToSession(session, {
type: 'chat:activity',
payload: {
requestId: request.requestId,
line: compactEntry.line,
lineCount: activityLines.length,
lineNo: compactEntry.lineNo,
},
});
const activityMessage = createActivityLogMessage(request.requestId, activityLines);
if (activityMessage) {
this.updateMessageInSession(session, activityMessage);
}
};
chatRuntimeService.startJob({
sessionId: session.sessionId,
requestId: request.requestId,
mode: request.mode,
text: request.text,
});
chatRuntimeService.registerRunningControl(request.requestId, {
cancel: () => this.cancelRuntimeJob(request.requestId),
});
chatRuntimeService.appendLog(request.requestId, `요청을 처리합니다. mode=${request.mode}`);
appendActivityLine(`# 상태: 요청을 처리합니다. mode=${request.mode}`);
this.emitJobState(session, {
requestId: request.requestId,
status: 'started',
mode: request.mode,
queueSize: session.queue.length,
message:
request.mode === 'direct'
? '즉시 요청 실행 중'
: `요청 처리 중${session.queue.length > 0 ? ` · 대기열 ${session.queue.length}` : ''}`,
});
this.sendToSession(session, {
type: 'chat:message',
payload: createMessage(
'system',
request.mode === 'direct'
? '즉시 요청 실행 중입니다.'
: `요청 실행 중입니다.${session.queue.length > 0 ? ` 남은 대기열 ${session.queue.length}` : ''}`,
request.requestId,
),
});
const stopProgressTimer = () => {
if (progressTimer !== null) {
clearTimeout(progressTimer);
progressTimer = null;
}
};
const progressMessages = buildProgressMessages(request.text);
let progressIndex = progressMessages.length > 1 ? 1 : 0;
let lastProgressMessage = progressMessages[0] ?? '';
let lastMeaningfulProgressSummary = '';
let progressTimer: ReturnType<typeof setTimeout> | null = null;
const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => {
const normalizedMessage = normalizeProgressSummary(nextMessage);
if (!normalizedMessage || normalizedMessage === lastProgressMessage) {
return false;
}
lastProgressMessage = normalizedMessage;
chatRuntimeService.appendLog(request.requestId, normalizedMessage);
if (options?.appendActivity !== false) {
appendActivityLine(`# 진행: ${normalizedMessage}`);
}
this.sendToSession(session, {
type: 'chat:message',
payload: createMessage('system', normalizedMessage, request.requestId),
});
return true;
};
const scheduleFallbackProgressMessage = () => {
if (progressIndex >= progressMessages.length) {
return;
}
stopProgressTimer();
progressTimer = setTimeout(() => {
const nextMessage = progressMessages[progressIndex] ?? '';
if (!nextMessage || lastMeaningfulProgressSummary) {
stopProgressTimer();
return;
}
if (emitSystemProgressMessage(nextMessage)) {
progressIndex += 1;
}
stopProgressTimer();
}, 2200);
};
const codexReplyMessage = createMessage('codex', '', request.requestId);
try {
chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.');
appendActivityLine('# 진행: 요청 분석을 시작합니다.');
this.sendToSession(session, {
type: 'chat:message',
payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId),
});
scheduleFallbackProgressMessage();
this.updateMessageInSession(session, {
...codexReplyMessage,
text: '응답을 준비하고 있습니다...',
timestamp: resolveResponseTimestamp(request.requestedAtMs),
});
const reply = await buildCodexReply(
request.context ?? session.context ?? null,
request.text,
session.sessionId,
request.requestId,
{
omitPromptHistory: request.omitPromptHistory === true,
requestedAt: new Date(request.requestedAtMs),
},
(partialReply) => {
stopProgressTimer();
if (!hasAnnouncedStreaming) {
hasAnnouncedStreaming = true;
chatRuntimeService.appendLog(request.requestId, '응답을 실시간으로 전송 중입니다.');
appendActivityLine('# 진행: 응답을 실시간으로 전송 중입니다.');
}
this.updateMessageInSession(session, {
...codexReplyMessage,
text: partialReply,
timestamp: resolveResponseTimestamp(request.requestedAtMs),
});
},
(activityLine) => {
appendActivityLine(activityLine);
const activitySummary = summarizeActivityProgressLine(activityLine);
if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) {
lastMeaningfulProgressSummary = activitySummary;
stopProgressTimer();
emitSystemProgressMessage(activitySummary, { appendActivity: false });
}
},
() => this.cancelledRequestIds.has(request.requestId),
);
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');
const finalCodexReplyMessage = {
...codexReplyMessage,
...(() => {
const extracted = extractChatMessageParts(reply);
return {
text: extracted.strippedText,
parts: extracted.parts,
};
})(),
timestamp: resolveResponseTimestamp(request.requestedAtMs),
};
this.sendToSession(
session,
{
type: 'chat:message',
payload: finalCodexReplyMessage,
},
{
skipOfflineNotification: true,
},
);
// Final replies must be durably stored before the request is marked complete,
// otherwise replay/reload can be stuck on the placeholder text.
await this.persistConversationMessage(session, finalCodexReplyMessage);
await upsertChatConversationRequest(session.sessionId, {
requestId: request.requestId,
status: 'completed',
statusMessage: '요청 처리 완료',
responseMessageId: finalCodexReplyMessage.id,
responseText: finalCodexReplyMessage.text,
});
await refreshChatSessionReferenceForRequest({
sessionId: session.sessionId,
requestId: request.requestId,
context: request.context ?? session.context ?? null,
input: request.text,
requestStatus: 'completed',
requestedAt: new Date(request.requestedAtMs),
completedAt: new Date(),
});
const terminalMessageEnvelope = this.sendToSession(session, {
type: 'chat:message',
payload: createMessage(
'system',
request.mode === 'direct'
? '즉시 요청 처리가 끝났습니다.'
: `요청 처리가 끝났습니다.${session.queue.length > 0 ? ` 대기열 ${session.queue.length}건이 이어집니다.` : ''}`,
request.requestId,
),
});
session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId;
this.emitJobState(session, {
requestId: request.requestId,
status: 'completed',
mode: request.mode,
queueSize: session.queue.length,
message: '요청 처리 완료',
});
chatRuntimeService.finishJob(request.requestId, 'completed');
await this.sendOfflineNotificationBestEffort(session, finalCodexReplyMessage);
} catch (error) {
const wasCancelled = this.cancelledRequestIds.has(request.requestId);
terminalStatus = wasCancelled ? 'cancelled' : 'failed';
const failureResponseText =
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
if (failureResponseText) {
const extractedFailureReply = extractChatMessageParts(failureResponseText);
const failedCodexReplyMessage = {
...codexReplyMessage,
text: extractedFailureReply.strippedText,
parts: extractedFailureReply.parts,
timestamp: resolveResponseTimestamp(request.requestedAtMs),
};
this.sendToSession(
session,
{
type: 'chat:message',
payload: failedCodexReplyMessage,
},
{
skipOfflineNotification: true,
},
);
await this.persistConversationMessage(session, failedCodexReplyMessage);
await upsertChatConversationRequest(session.sessionId, {
requestId: request.requestId,
status: wasCancelled ? 'cancelled' : 'failed',
statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
responseMessageId: failedCodexReplyMessage.id,
responseText: failedCodexReplyMessage.text,
});
}
chatRuntimeService.appendLog(
request.requestId,
wasCancelled
? '사용자 요청으로 실행이 중단되었습니다.'
: error instanceof Error
? `실행 오류: ${error.message}`
: '실행 오류가 발생했습니다.',
);
appendActivityLine(
wasCancelled
? '# 상태: 사용자 요청으로 실행이 중단되었습니다.'
: error instanceof Error
? `# 오류: ${error.message}`
: '# 오류: 실행 오류가 발생했습니다.',
);
const terminalErrorEnvelope = this.sendToSession(session, {
type: 'chat:message',
payload: createMessage(
'system',
wasCancelled ? '요청 실행이 중단되었습니다.' : '요청 처리 중 오류가 발생했습니다.',
request.requestId,
),
});
session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId;
this.emitJobState(session, {
requestId: request.requestId,
status: 'failed',
mode: request.mode,
queueSize: session.queue.length,
message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
});
await refreshChatSessionReferenceForRequest({
sessionId: session.sessionId,
requestId: request.requestId,
context: request.context ?? session.context ?? null,
input: request.text,
requestStatus: wasCancelled ? 'cancelled' : 'failed',
requestedAt: new Date(request.requestedAtMs),
completedAt: new Date(),
});
throw error;
} finally {
stopProgressTimer();
activeChatProcessRegistry.delete(request.requestId);
if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) {
chatRuntimeService.finishJob(request.requestId, terminalStatus);
}
this.cancelledRequestIds.delete(request.requestId);
session.activeRequestCount = Math.max(0, session.activeRequestCount - 1);
this.tryStartNextQueuedRequest(session);
}
}
}