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

@@ -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