Fix chat type persistence and board flow

This commit is contained in:
2026-04-24 15:56:30 +09:00
parent c07b0b12af
commit d53532508b
38 changed files with 2358 additions and 912 deletions

View File

@@ -32,6 +32,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
async function ensureAppConfigTable() {

View File

@@ -28,7 +28,7 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -82,7 +82,7 @@ function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: defaultItem.description,
description: existingItem.description || defaultItem.description,
behaviorType: defaultItem.behaviorType,
});
}

View File

@@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
assert.equal(
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
}),
buildBoardPostPlanNote(
' 알림 개선 ',
'본문 첫 줄\n본문 둘째 줄\n',
[],
{
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
},
),
[
'# 자동화 작업메모',
'',
@@ -27,10 +32,15 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
assert.equal(
buildBoardPostPlanNote('작업', '본문', {
name: '빈 context 유형',
description: ' ',
}),
buildBoardPostPlanNote(
'작업',
'본문',
[],
{
name: '빈 context 유형',
description: ' ',
},
),
[
'# 자동화 작업메모',
'',
@@ -48,6 +58,42 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
);
});
test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
assert.equal(
buildBoardPostPlanNote(
'작업',
'본문',
[
{
id: 'attachment-1',
name: 'spec.png',
path: 'public/.codex_chat/test/resource/uploads/spec.png',
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
size: 1280,
mimeType: 'image/png',
},
],
null,
),
[
'# 자동화 작업메모',
'',
'- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
'선택된 자동화 유형 context 없음',
'',
'## 요청 본문',
'본문',
'',
'## 첨부 파일',
'- spec.png: public/.codex_chat/test/resource/uploads/spec.png',
].join('\n'),
);
});
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');

View File

@@ -17,6 +17,16 @@ export const BOARD_POSTS_TABLE = 'board_posts';
export const boardPostPayloadSchema = z.object({
title: z.string().trim().min(1).max(200),
content: z.string().min(1).max(200000),
attachments: z.array(
z.object({
id: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(255),
path: z.string().trim().min(1).max(2000),
publicUrl: z.string().trim().min(1).max(2000),
size: z.coerce.number().int().min(0).max(10 * 1024 * 1024),
mimeType: z.string().trim().min(1).max(200),
}),
).max(20).default([]),
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
});
@@ -25,6 +35,7 @@ export type BoardPostItem = {
title: string;
content: string;
preview: string;
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'];
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
automationPlanItemId: number | null;
automationReceivedAt: string | null;
@@ -57,12 +68,22 @@ function createPreview(content: string) {
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
const content = String(row.content ?? '');
const rawAttachments = row.attachments_json ?? row.attachments ?? '[]';
let attachments: BoardPostItem['attachments'] = [];
try {
const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments;
attachments = boardPostPayloadSchema.shape.attachments.parse(parsed);
} catch {
attachments = [];
}
return {
id: Number(row.id ?? 0),
title: String(row.title ?? ''),
content,
preview: createPreview(content),
attachments,
automationType: resolveStoredAutomationTypeId(row),
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
? null
@@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
return Boolean(row.automation_received_at || row.automation_plan_item_id);
}
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloadSchema>['attachments']) {
const lines = attachments
.map((attachment) => {
const label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일';
const path = attachment.path.trim();
if (!path) {
return null;
}
return `- ${label}: ${path}`;
})
.filter((line): line is string => Boolean(line));
if (lines.length === 0) {
return [];
}
return ['## 첨부 파일', ...lines];
}
export function buildBoardPostPlanNote(
title: string,
content: string,
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
) {
const normalizedTitle = title.trim();
const normalizedContent = content.trim();
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
@@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio
'',
'## 요청 본문',
normalizedContent,
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
]
.filter((line): line is string => line !== null)
.join('\n');
@@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() {
['content', (table) => table.text('content').notNullable().defaultTo('')],
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -230,6 +279,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
const insertQuery = db(BOARD_POSTS_TABLE).insert({
title: parsedPayload.title,
content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
created_at: db.fn.now(),
@@ -276,11 +326,12 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim();
const attachments = mapBoardPostRow(currentRow).attachments;
const workId = `board-post-${id}`;
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
const insertQuery = trx(PLAN_TABLE).insert({
work_id: workId,
note: buildBoardPostPlanNote(title, content, automationType),
note: buildBoardPostPlanNote(title, content, attachments, automationType),
automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
status: '등록',
@@ -344,6 +395,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
.update({
title: parsedPayload.title,
content: parsedPayload.content,
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
updated_at: trx.fn.now(),

View File

@@ -1070,15 +1070,25 @@ export async function updateChatConversationContext(
return null;
}
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
const requestedChatTypeId = payload.chatTypeId?.trim() || null;
const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null;
const requestedContextLabel = payload.contextLabel?.trim() || null;
const requestedContextDescription = payload.contextDescription?.trim() || null;
await db(CHAT_CONVERSATION_TABLE)
.where({ session_id: sessionId.trim() })
.update({
title: payload.title?.trim() || current.title || '새 대화',
client_id: normalizedClientId || current.client_id || null,
chat_type_id: payload.chatTypeId?.trim() || null,
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
context_label: payload.contextLabel?.trim() || null,
context_description: payload.contextDescription?.trim() || 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,
notify_offline:
normalizedClientId == null && payload.notifyOffline != null
? payload.notifyOffline
@@ -1613,14 +1623,21 @@ export async function appendChatConversationMessage(
.update({
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
title: nextTitle,
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null,
last_chat_type_id:
conversation.chatTypeId?.trim() ||
currentConversation?.last_chat_type_id ||
currentConversation?.chat_type_id ||
currentConversation?.last_chat_type_id ||
conversation.chatTypeId?.trim() ||
conversation.lastChatTypeId?.trim() ||
null,
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
context_label:
currentConversation?.chat_type_id || currentConversation?.context_label
? currentConversation?.context_label || null
: conversation.contextLabel?.trim() || null,
context_description:
currentConversation?.chat_type_id || currentConversation?.context_description
? currentConversation?.context_description || null
: conversation.contextDescription?.trim() || null,
notify_offline:
conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
updated_at: db.fn.now(),

View File

@@ -8,6 +8,7 @@ import {
collectOfflineNotificationClientIds,
createActivityLogMessage,
extractDiffCodeBlocks,
extractCodexStreamText,
fitActivityLogLines,
isAutomationRegistrationCountRequest,
resolveResponseTimestamp,
@@ -116,6 +117,45 @@ test('createActivityLogMessage keeps fitted activity history instead of the late
);
});
test('extractCodexStreamText ignores command execution item completions', () => {
assert.deepEqual(
extractCodexStreamText({
type: 'item.completed',
item: {
id: 'item_1',
type: 'command_execution',
command: '/bin/bash -lc pwd',
aggregated_output: '/workspace/main-project\n',
exit_code: 0,
status: 'completed',
},
}),
{
type: 'item.completed',
completedText: '',
deltaText: '',
},
);
});
test('extractCodexStreamText keeps completed agent messages', () => {
assert.deepEqual(
extractCodexStreamText({
type: 'item.completed',
item: {
id: 'item_2',
type: 'agent_message',
text: '최종 응답입니다.',
},
}),
{
type: 'item.completed',
completedText: '최종 응답입니다.',
deltaText: '',
},
);
});
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
});

View File

@@ -1041,11 +1041,25 @@ function collectCodexTextFragments(value: unknown): string[] {
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 = collectCodexTextFragments(parsed.item).join('');
const completedText = extractCompletedAgentMessageText(parsed.item);
return {
type,
completedText,
@@ -3186,9 +3200,19 @@ export class ChatService {
});
const progressMessages = buildProgressMessages(request.text);
let progressIndex = 0;
let progressIndex = progressMessages.length > 1 ? 1 : 0;
let lastProgressMessage = progressMessages[0] ?? '';
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
if (!nextMessage || nextMessage === lastProgressMessage) {
if (progressIndex >= progressMessages.length - 1) {
stopProgressTimer();
}
return;
}
lastProgressMessage = nextMessage;
chatRuntimeService.appendLog(request.requestId, nextMessage);
appendActivityLine(`# 진행: ${nextMessage}`);
@@ -3199,6 +3223,8 @@ export class ChatService {
if (progressIndex < progressMessages.length - 1) {
progressIndex += 1;
} else {
stopProgressTimer();
}
}, 2200);

View File

@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
const createdPost = await createBoardPost({
title: buildErrorLogBoardPostTitle(candidate),
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [],
automationType: 'none',
});