chore: update live chat and work server changes

This commit is contained in:
2026-04-26 16:37:06 +09:00
parent 63e5d263a7
commit 20a6333ed2
38 changed files with 2078 additions and 2281 deletions

View File

@@ -59,7 +59,7 @@ npm run server-command:runner
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형과 현재 화면 문맥을 기준으로 동작하고, 자동화 유형 context 기본 문맥으로 섞지 않습니다.
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context 기본 문맥으로 섞지 않습니다.
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.

View File

@@ -148,7 +148,22 @@ function canViewAllConversations(request: { headers: Record<string, unknown> })
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
}
function applyChatApiNoStoreHeaders(reply: FastifyReply) {
reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate');
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
reply.header('Surrogate-Control', 'no-store');
}
export async function registerChatRoutes(app: FastifyInstance) {
app.addHook('onSend', async (request, reply, payload) => {
if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) {
applyChatApiNoStoreHeaders(reply);
}
return payload;
});
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);

View File

@@ -6,6 +6,8 @@ const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
} as const;
type ChatPermissionRole = 'guest' | 'token-user';
@@ -24,7 +26,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -234,6 +236,8 @@ export type AppConfigSnapshot = {
maxContextMessages?: number;
maxContextChars?: number;
codexLiveMaxExecutionSeconds?: number;
codexLiveIdleTimeoutSeconds?: number;
receiveRoomNotifications?: boolean;
};
automation?: {
autoRefreshEnabled?: boolean;
@@ -290,6 +294,16 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
60,
7200,
),
codexLiveIdleTimeoutSeconds: normalizeIntegerInRange(
chat.codexLiveIdleTimeoutSeconds,
DEFAULT_CHAT_APP_CONFIG.codexLiveIdleTimeoutSeconds,
30,
3600,
),
receiveRoomNotifications:
typeof chat.receiveRoomNotifications === 'boolean'
? chat.receiveRoomNotifications
: DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications,
},
worklogAutomation:
Object.keys(worklogAutomation).length > 0

View File

@@ -4,6 +4,7 @@ import {
buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage,
mergeChatConversationRequestStatus,
resolveNextConversationContextValue,
resolveNextConversationChatTypeId,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
@@ -27,6 +28,12 @@ test('resolveNextConversationChatTypeId falls back to the stored chat type when
assert.equal(resolveNextConversationChatTypeId(null, null), null);
});
test('resolveNextConversationContextValue prefers the requested chat type context', () => {
assert.equal(resolveNextConversationContextValue('old context', 'new context'), 'new context');
assert.equal(resolveNextConversationContextValue('old context', ' '), 'old context');
assert.equal(resolveNextConversationContextValue(null, 'new context'), 'new context');
});
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
assert.equal(
buildChatConversationRequestPatchFromMessage({

View File

@@ -1083,12 +1083,8 @@ export async function updateChatConversationContext(
client_id: normalizedClientId || current.client_id || null,
chat_type_id: nextChatTypeId,
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
context_label:
currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null,
context_description:
currentChatTypeId != null
? current.context_description || null
: requestedContextDescription || current.context_description || null,
context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel),
context_description: resolveNextConversationContextValue(current.context_description, requestedContextDescription),
notify_offline:
normalizedClientId == null && payload.notifyOffline != null
? payload.notifyOffline
@@ -1109,6 +1105,12 @@ export function resolveNextConversationChatTypeId(currentChatTypeId?: string | n
return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null;
}
export function resolveNextConversationContextValue(currentValue?: string | null, requestedValue?: string | null) {
const normalizedRequestedValue = String(requestedValue ?? '').trim() || null;
const normalizedCurrentValue = String(currentValue ?? '').trim() || null;
return normalizedRequestedValue ?? normalizedCurrentValue ?? null;
}
export async function listChatConversations(
clientId?: string | null,
limit = 50,

View File

@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
import {
collectOfflineNotificationClientIds,
createActivityLogMessage,
buildAgenticCodexPrompt,
extractDiffCodeBlocks,
extractCodexStreamText,
fitActivityLogLines,
@@ -91,6 +92,33 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => {
);
});
test('buildAgenticCodexPrompt treats chat type context as required instructions', () => {
const prompt = buildAgenticCodexPrompt(
{
pageId: null,
pageTitle: 'Codex Live',
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://test.sm-home.cloud/chat/live',
chatTypeLabel: '모바일 검증',
chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.',
},
'화면 확인해줘',
'session-a',
{
recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'],
omittedHistoryCount: 2,
},
);
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
assert.match(prompt, /상위 필수 지시/);
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
assert.match(prompt, /### 반드시 지킬 context 원문/);
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);

View File

@@ -168,6 +168,7 @@ type ChatSessionState = {
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
context: ChatContext | null;
}>;
activeRequestCount: number;
pendingQueueReleaseEventId: number | null;
@@ -894,6 +895,16 @@ function summarizeCodexOutput(output: string) {
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();
@@ -1429,7 +1440,41 @@ async function buildRecentChatPromptHistory(
};
}
function buildAgenticCodexPrompt(
function cloneChatContext(context: ChatContext | null): ChatContext | null {
return context ? { ...context } : null;
}
function buildChatTypeInstructionBlock(context: ChatContext | null) {
const chatTypeLabel = context?.chatTypeLabel?.trim() || '';
const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
const hasSpecificChatType = Boolean(chatTypeLabel && chatTypeLabel !== '일반 요청');
const hasContextDescription = Boolean(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 원문',
chatTypeDescription || '선택된 채팅 유형 context 원문 없음',
];
}
export function buildAgenticCodexPrompt(
context: ChatContext | null,
input: string,
sessionId: string,
@@ -1460,6 +1505,8 @@ function buildAgenticCodexPrompt(
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
...buildChatTypeInstructionBlock(context),
'',
'응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
@@ -1468,21 +1515,13 @@ function buildAgenticCodexPrompt(
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
'',
'채팅 유형 문맥(우선 적용):',
context?.chatTypeLabel && context.chatTypeLabel.trim() !== '일반 요청'
? `- chatTypeLabel: ${context.chatTypeLabel}`
: '- chatTypeLabel: 없음',
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
`- topMenu: ${context?.topMenu ?? '없음'}`,
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
'',
'최근 대화 문맥:',
'최근 대화 문맥(보조 참조):',
...(recentHistoryLines.length > 0
? [
...recentHistoryLines.map((line) => `- ${line}`),
@@ -1652,6 +1691,11 @@ async function runAgenticCodexReply(
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 prompt = buildAgenticCodexPrompt(context, input, sessionId, {
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
@@ -1663,6 +1707,23 @@ async function runAgenticCodexReply(
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;
@@ -1680,177 +1741,222 @@ async function runAgenticCodexReply(
},
});
await new Promise<void>(async (resolve, reject) => {
const emitProgress = (nextText: string) => {
const normalizedProgress = nextText.trim();
chatRuntimeService.appendLog(
requestId,
`실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}`,
);
onActivity?.(
`# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}`,
);
if (!normalizedProgress || normalizedProgress === lastProgressText) {
return;
}
try {
await new Promise<void>(async (resolve, reject) => {
const emitProgress = (nextText: string) => {
const normalizedProgress = nextText.trim();
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,
}),
});
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 {
if (!normalizedProgress || normalizedProgress === lastProgressText) {
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;
chatRuntimeService.attachProcess(requestId, pid);
chatRuntimeService.appendLog(
requestId,
pid ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.',
);
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) {
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();
}
lastProgressText = normalizedProgress;
streamedOutput = normalizedProgress;
onProgress?.(normalizedProgress);
};
while (true) {
try {
await throwIfCancelled();
const { value, done } = await reader.read();
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 (done) {
break;
if (!response.ok) {
reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.'));
return;
}
jsonLineBuffer += decoder.decode(value, { stream: true });
const lines = jsonLineBuffer.split('\n');
jsonLineBuffer = lines.pop() ?? '';
await throwIfCancelled();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!response.body) {
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
return;
}
if (!line) {
continue;
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;
}
handleRunnerLine(line);
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 === '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) {
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);
}
const trailingLine = jsonLineBuffer.trim();
if (trailingLine) {
handleRunnerLine(trailingLine);
}
if (remoteErrorMessage) {
reject(new Error(remoteErrorMessage));
return;
}
if (remoteErrorMessage) {
reject(new Error(remoteErrorMessage));
return;
}
resolve();
} catch (error) {
reject(error);
resolve();
} catch (error) {
reject(error);
}
});
} catch (error) {
const failureResponseText = await finalizeReplyOutput();
if (failureResponseText) {
throw new ChatRuntimeExecutionError(error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText);
}
});
const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
// 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);
throw error;
}
return rewrittenOutput;
return await finalizeReplyOutput();
}
async function getTodayAutomationRegistrationCounts() {
@@ -3066,6 +3172,15 @@ export class ChatService {
}),
...contextOverride,
};
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);
@@ -3078,6 +3193,7 @@ export class ChatService {
text: trimmed,
mode,
requestedAtMs,
context: cloneChatContext(state.context),
};
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
@@ -3129,6 +3245,7 @@ export class ChatService {
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
context: ChatContext | null;
},
) {
let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
@@ -3258,7 +3375,7 @@ export class ChatService {
});
const reply = await buildCodexReply(
session.context ?? null,
request.context ?? session.context ?? null,
request.text,
session.sessionId,
request.requestId,
@@ -3334,6 +3451,37 @@ export class ChatService {
} catch (error) {
const wasCancelled = this.cancelledRequestIds.has(request.requestId);
terminalStatus = wasCancelled ? 'cancelled' : 'failed';
const failureResponseText =
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
if (failureResponseText) {
const failedCodexReplyMessage = {
...codexReplyMessage,
text: failureResponseText,
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