Fix chat type persistence and board flow
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
|
||||
const createdPost = await createBoardPost({
|
||||
title: buildErrorLogBoardPostTitle(candidate),
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user