diff --git a/apps/dashboard/src/roomThread.test.ts b/apps/dashboard/src/roomThread.test.ts index 779ea8f..90af490 100644 --- a/apps/dashboard/src/roomThread.test.ts +++ b/apps/dashboard/src/roomThread.test.ts @@ -97,4 +97,117 @@ describe('room thread entries', () => { turnNumber: 1, }); }); + + it('deduplicates continuation chunks that are contained in a paired output', () => { + const continuation = + 'PR 설명에 TaskPanel.tsx + statusLabel 이동+테스트 2개 모두 명시되어 있고 검증도 완료됐습니다.'; + const entries = buildRoomThreadEntries({ + messages: [ + message({ + id: 'bot-continuation', + senderName: '리뷰어', + content: continuation, + timestamp: '2026-04-28T01:02:30.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + ], + outputs: [ + output({ + id: 8, + role: '리뷰어', + outputText: `STEP_DONE\n\n파일 검증 완료\n\n${continuation}\n\n다음 슬라이스 권고`, + }), + ], + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: 'out:8', + senderName: '리뷰어', + sourceKind: 'agent_output', + }); + }); + + it('merges adjacent bot chunks from the same sender', () => { + const entries = buildRoomThreadEntries({ + messages: [ + message({ + id: 'reviewer-1', + senderName: '리뷰어', + content: 'STEP_DONE — PR #63 검증 완료\n\n파일 검증', + timestamp: '2026-04-28T01:02:00.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + message({ + id: 'reviewer-2', + senderName: '리뷰어', + content: 'PR 설명에 추출 모듈 전부 명시\n\n다음 슬라이스 권고', + timestamp: '2026-04-28T01:02:03.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + ], + outputs: [], + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: 'reviewer-1+reviewer-2', + senderName: '리뷰어', + sourceKind: 'bot', + }); + expect(entries[0].content).toContain('파일 검증'); + expect(entries[0].content).toContain('다음 슬라이스 권고'); + }); + + it('keeps adjacent human messages separate', () => { + const entries = buildRoomThreadEntries({ + messages: [ + message({ + id: 'human-1', + content: '첫 번째 요청', + timestamp: '2026-04-28T01:02:00.000Z', + }), + message({ + id: 'human-2', + content: '두 번째 요청', + timestamp: '2026-04-28T01:02:03.000Z', + }), + ], + outputs: [], + }); + + expect(entries.map((entry) => entry.id)).toEqual(['human-1', 'human-2']); + }); + + it('keeps adjacent bot status outputs separate', () => { + const entries = buildRoomThreadEntries({ + messages: [ + message({ + id: 'reviewer-status-1', + senderName: '리뷰어', + content: 'STEP_DONE — PR #63 검증 완료', + timestamp: '2026-04-28T01:02:00.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + message({ + id: 'reviewer-status-2', + senderName: '리뷰어', + content: 'STEP_DONE — PR #64 검증 완료', + timestamp: '2026-04-28T01:02:03.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + ], + outputs: [], + }); + + expect(entries.map((entry) => entry.id)).toEqual([ + 'reviewer-status-1', + 'reviewer-status-2', + ]); + }); }); diff --git a/apps/dashboard/src/roomThread.ts b/apps/dashboard/src/roomThread.ts index 31ad4d6..8bf3eab 100644 --- a/apps/dashboard/src/roomThread.ts +++ b/apps/dashboard/src/roomThread.ts @@ -81,6 +81,11 @@ function normalizeForDedupe(value: string): string { return value.replace(/\s+/g, ' ').trim(); } +const MIN_SUBSTRING_DEDUPE_LENGTH = 40; +const BOT_CHUNK_MERGE_WINDOW_MS = 15_000; +const STATUS_OUTPUT_PREFIX_RE = + /^(?:STEP_DONE|TASK_DONE|DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/; + function isDuplicateOutputMessage( message: RoomThreadEntry, outputEntries: RoomThreadEntry[], @@ -96,7 +101,11 @@ function isDuplicateOutputMessage( const contentMatches = outputText === messageText || outputText.startsWith(messageText) || - messageText.startsWith(outputText); + messageText.startsWith(outputText) || + (messageText.length >= MIN_SUBSTRING_DEDUPE_LENGTH && + outputText.includes(messageText)) || + (outputText.length >= MIN_SUBSTRING_DEDUPE_LENGTH && + messageText.includes(outputText)); if (!contentMatches) return false; const outputTime = new Date(output.timestamp).getTime(); if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) { @@ -106,6 +115,57 @@ function isDuplicateOutputMessage( }); } +function entryTimeMs(entry: RoomThreadEntry): number | null { + const time = new Date(entry.timestamp).getTime(); + return Number.isFinite(time) ? time : null; +} + +function shouldMergeAdjacentBotChunk( + previous: RoomThreadEntry, + next: RoomThreadEntry, +): boolean { + if ( + previous.sourceKind === 'agent_output' || + next.sourceKind === 'agent_output' + ) { + return false; + } + if (previous.senderName !== next.senderName) return false; + if (previous.sourceKind !== next.sourceKind) return false; + if (!previous.isBotMessage || !next.isBotMessage) return false; + if (STATUS_OUTPUT_PREFIX_RE.test(next.content.trimStart())) return false; + const previousTime = entryTimeMs(previous); + const nextTime = entryTimeMs(next); + if (previousTime === null || nextTime === null) { + return previous.timestamp === next.timestamp; + } + return Math.abs(nextTime - previousTime) <= BOT_CHUNK_MERGE_WINDOW_MS; +} + +function mergeEntryContent(left: string, right: string): string { + const trimmedLeft = left.trimEnd(); + const trimmedRight = right.trimStart(); + if (!trimmedLeft) return trimmedRight; + if (!trimmedRight) return trimmedLeft; + return `${trimmedLeft}\n\n${trimmedRight}`; +} + +function mergeAdjacentBotChunks(entries: RoomThreadEntry[]): RoomThreadEntry[] { + const merged: RoomThreadEntry[] = []; + for (const entry of entries) { + const previous = merged.at(-1); + if (previous && shouldMergeAdjacentBotChunk(previous, entry)) { + previous.id = `${previous.id}+${entry.id}`; + previous.content = mergeEntryContent(previous.content, entry.content); + previous.verdict ??= entry.verdict; + previous.turnNumber ??= entry.turnNumber; + continue; + } + merged.push({ ...entry }); + } + return merged; +} + export function buildRoomThreadEntries({ messages, outputs, @@ -128,7 +188,8 @@ export function buildRoomThreadEntries({ .filter((message) => !isInternalProtocolPayload(message.content)) .map(toMessageEntry); - return [...chatEntries, ...optimisticPending, ...outputEntries].sort((a, b) => - a.timestamp.localeCompare(b.timestamp), + const entries = [...chatEntries, ...optimisticPending, ...outputEntries].sort( + (a, b) => a.timestamp.localeCompare(b.timestamp), ); + return mergeAdjacentBotChunks(entries); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index df2de40..e472c21 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -4234,6 +4234,9 @@ progress::-moz-progress-bar { background: rgba(255, 255, 255, 0.06); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-word; } .parsed-body pre {