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

@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}"
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
@@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
exit 1
fi
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
if [ -n "$RUNNER_PIDS" ]; then
kill $RUNNER_PIDS || true
if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
chmod +x "$SUPERVISOR_SCRIPT"
fi
if [ -f "$SUPERVISOR_PID_FILE" ]; then
SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true)
if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then
kill -HUP "$SUPERVISOR_PID"
echo "server-command-runner reload requested"
exit 0
fi
rm -f "$SUPERVISOR_PID_FILE"
fi
LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
if [ -n "$LEGACY_RUNNER_PIDS" ]; then
kill $LEGACY_RUNNER_PIDS || true
sleep 1
fi
setsid env \
PROJECT_ROOT="$PROJECT_ROOT" \
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
SERVER_COMMAND_RUNNER_SCRIPT="$RUNNER_SCRIPT" \
SERVER_COMMAND_RUNNER_NODE_BIN="$RUNNER_NODE_BIN" \
SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE="$SUPERVISOR_PID_FILE" \
"$SUPERVISOR_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
echo "server-command-runner restart requested"

View File

@@ -4,6 +4,21 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
cd "$REPO_ROOT"
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
if docker inspect work-server >/dev/null 2>&1; then
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
case "$SUPERVISOR_CMD" in
*work-server-supervisor*)
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
echo "work-server reload requested"
exit 0
fi
;;
esac
fi
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server

View File

@@ -2,6 +2,7 @@ create table if not exists board_posts (
id serial primary key,
title varchar(200) not null,
content text not null,
attachments_json text not null default '[]',
automation_plan_item_id integer null,
automation_received_at timestamptz null,
created_at timestamptz not null default now(),

View File

@@ -0,0 +1,4 @@
.docker
node_modules
dist
npm-debug.log*

View File

@@ -11,4 +11,7 @@ COPY src ./src
RUN npm run build
CMD ["npm", "run", "start"]
COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor
RUN chmod +x /usr/local/bin/work-server-supervisor
CMD ["/usr/local/bin/work-server-supervisor"]

View File

@@ -22,6 +22,8 @@ services:
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro
@@ -50,3 +52,6 @@ services:
networks:
work-backend:
name: work-backend
volumes:
work-server-node-modules:

View File

@@ -0,0 +1,96 @@
#!/bin/sh
set -eu
APP_ROOT="${APP_ROOT:-/app}"
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
LOCK_FILE="$APP_ROOT/package-lock.json"
LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256"
CHILD_PID=""
STOP_REQUESTED="0"
RELOAD_REQUESTED="0"
mkdir -p "$STATE_DIR"
cd "$APP_ROOT"
log() {
printf '[work-server-supervisor] %s\n' "$*"
}
ensure_dependencies() {
if [ ! -f "$LOCK_FILE" ]; then
log "package-lock.json not found; skipping npm ci"
return 0
fi
CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}')
PREVIOUS_HASH=""
if [ -f "$LOCK_HASH_FILE" ]; then
PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE")
fi
if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then
log "installing dependencies"
npm ci --legacy-peer-deps
printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE"
fi
}
prepare_runtime() {
ensure_dependencies
log "building latest source"
npm run build
}
start_child() {
log "starting server process"
npm run start &
CHILD_PID=$!
}
request_reload() {
log "reload requested"
if prepare_runtime; then
RELOAD_REQUESTED="1"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
else
log "reload aborted because build failed; keeping current process"
fi
}
request_stop() {
STOP_REQUESTED="1"
log "shutdown requested"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
}
trap 'request_reload' HUP
trap 'request_stop' INT TERM
prepare_runtime
while :; do
start_child
set +e
wait "$CHILD_PID"
EXIT_CODE=$?
set -e
CHILD_PID=""
if [ "$STOP_REQUESTED" = "1" ]; then
exit "$EXIT_CODE"
fi
if [ "$RELOAD_REQUESTED" = "1" ]; then
RELOAD_REQUESTED="0"
continue
fi
log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
sleep 2
done

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',
});