feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -27,6 +27,7 @@ import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } 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,
@@ -329,6 +330,26 @@ function createChatQuestionAnswerNotificationBody(args: {
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 ?? '';
@@ -1584,9 +1605,6 @@ function buildChatSessionReferenceAutoSection(args: {
context: ChatContext | null;
sessionId: string;
requestId: string;
input: string;
recentHistoryLines: string[];
omittedHistoryCount: number;
}) {
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
@@ -1594,14 +1612,6 @@ function buildChatSessionReferenceAutoSection(args: {
const topMenu = args.context?.topMenu?.trim() || '없음';
const pageUrl = args.context?.pageUrl?.trim() || '없음';
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
const historyLines =
args.recentHistoryLines.length > 0
? args.recentHistoryLines.map((line) => `- ${line}`)
: ['- 최근 대화 없음'];
if (args.omittedHistoryCount > 0) {
historyLines.push(`- 최근 문맥 일부만 포함했습니다. 이전 ${args.omittedHistoryCount}개 메시지는 제외되었습니다.`);
}
return [
CHAT_SESSION_REFERENCE_AUTO_START,
@@ -1617,12 +1627,6 @@ function buildChatSessionReferenceAutoSection(args: {
'',
'## 현재 채팅 유형 context',
chatTypeDescription,
'',
'## 최신 사용자 요청',
args.input.trim() || '없음',
'',
'## 최근 대화 요약',
...historyLines,
CHAT_SESSION_REFERENCE_AUTO_END,
].join('\n');
}
@@ -1635,30 +1639,19 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection:
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
].join('\n');
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
if (!trimmedExisting) {
return [
defaultHeader,
'',
autoSection,
'',
defaultManualSection,
'',
].join('\n');
return `${defaultHeader}\n\n${autoSection}\n`;
}
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
const manualSectionMatch = existingContent.match(/(^|\n)(## 수동 메모[\s\S]*)$/m);
const preservedManualSection = manualSectionMatch?.[2]?.trim() || defaultManualSection;
if (firstAutoStartIndex >= 0) {
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
return `${preservedHeader}\n\n${autoSection}\n`;
}
const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
}
export async function ensureChatSessionReferenceResource(args: {
@@ -1677,9 +1670,6 @@ export async function ensureChatSessionReferenceResource(args: {
context: args.context,
sessionId: args.sessionId,
requestId: args.requestId,
input: args.input,
recentHistoryLines: args.recentHistoryLines,
omittedHistoryCount: args.omittedHistoryCount,
});
let existingContent = '';
@@ -1765,7 +1755,7 @@ export function buildAgenticCodexPrompt(
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
...buildChatTypeInstructionBlock(context),
'',
@@ -1776,6 +1766,7 @@ export function buildAgenticCodexPrompt(
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[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"}` 형태를 사용하세요. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
@@ -1947,7 +1938,7 @@ async function runAgenticCodexReply(
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
const repoPath = resolveMainProjectRoot();
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot();
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
@@ -2856,19 +2847,33 @@ export class ChatService {
},
) {
if (session.isDeleted) {
return this.createSessionEnvelope(session, message);
const normalizedDeletedMessage =
message.type === 'chat:message'
? {
...message,
payload: normalizeStructuredChatMessage(message.payload),
}
: message;
return this.createSessionEnvelope(session, normalizedDeletedMessage);
}
const envelope = this.createSessionEnvelope(session, message);
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 (message.type === 'chat:message') {
this.persistConversationMessage(session, message.payload);
if (normalizedMessage.type === 'chat:message') {
this.persistConversationMessage(session, normalizedMessage.payload);
if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => {
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => {
this.logger.error(error, 'failed to send offline chat notification');
});
}
@@ -2878,9 +2883,10 @@ export class ChatService {
}
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
const normalizedMessage = normalizeStructuredChatMessage(message);
const envelope = this.createSessionEnvelope(session, {
type: 'chat:message:update',
payload: message,
payload: normalizedMessage,
});
this.retainEnvelopeForReplay(session, envelope);
@@ -2889,8 +2895,8 @@ export class ChatService {
// 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(message)) {
this.persistConversationMessage(session, message);
if (shouldPersistMessageUpdate(normalizedMessage)) {
this.persistConversationMessage(session, normalizedMessage);
}
return envelope;
@@ -3465,6 +3471,26 @@ export class ChatService {
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;