From 727aa3446e64bb6a7584b20ed96833419e1dace4 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 11:18:16 +0900 Subject: [PATCH] Fix room thread bot output parity Fix dashboard room thread rendering so regular Discord bot outputs from messages[] are shown when paired outputs are missing, while keeping watcher messages separated and deduplicating paired outputs. --- apps/dashboard/src/App.tsx | 104 +++----------------- apps/dashboard/src/roomThread.test.ts | 100 +++++++++++++++++++ apps/dashboard/src/roomThread.ts | 134 ++++++++++++++++++++++++++ vitest.config.ts | 1 + 4 files changed, 249 insertions(+), 90 deletions(-) create mode 100644 apps/dashboard/src/roomThread.test.ts create mode 100644 apps/dashboard/src/roomThread.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 1777e03..90ce56c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -57,6 +57,11 @@ import { type DashboardFreshness, type DashboardView, } from './DashboardNav'; +import { + buildRoomThreadEntries, + isInternalProtocolPayload, + isWatcherRoomMessage, +} from './roomThread'; import './styles.css'; interface DashboardState { @@ -512,17 +517,6 @@ function ParsedBody({ ); } -function isInternalProtocolPayload( - content: string | null | undefined, -): boolean { - if (!content) return false; - if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true; - if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) { - return true; - } - return false; -} - function senderRoleClass(value: string | null | undefined): string { const v = (value ?? '').toLowerCase(); if (v.includes('오너') || v.includes('owner')) return 'role-owner'; @@ -2845,85 +2839,15 @@ function RoomCardV2({ queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`); if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages); const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null; - const WATCHER_RE = - /^\s*(?:CI 완료|Build |Deploy |Lint |Release |\[CI\]|\[Watcher\]|GitHub Actions)/i; - const isWatcherMsg = (m: (typeof messages)[number]) => - m.sourceKind === 'bot' && WATCHER_RE.test(m.content); - const isCronTriggerMsg = (m: (typeof messages)[number]) => - m.sourceKind === 'trusted_external_bot'; - const lastWatcher = [...messages].reverse().find(isWatcherMsg) ?? null; - const watcherCount = messages.filter(isWatcherMsg).length; - const watcherMessages = messages.filter(isWatcherMsg); - type ThreadEntry = { - id: string; - senderName: string; - content: string; - timestamp: string; - isFromMe: boolean; - isBotMessage: boolean; - sourceKind: string; - verdict?: string | null; - turnNumber?: number; - }; - const humanMessages: ThreadEntry[] = messages - .filter( - (m) => - (m.sourceKind === 'human' || m.sourceKind === 'ipc_injected_human') && - !isInternalProtocolPayload(m.content), - ) - .map((m) => ({ - id: m.id, - senderName: m.senderName, - content: m.content, - timestamp: m.timestamp, - isFromMe: m.isFromMe, - isBotMessage: false, - sourceKind: m.sourceKind, - })); - const cronMessages: ThreadEntry[] = messages - .filter((m) => isCronTriggerMsg(m) && !isInternalProtocolPayload(m.content)) - .map((m) => ({ - id: m.id, - senderName: m.senderName, - content: m.content, - timestamp: m.timestamp, - isFromMe: false, - isBotMessage: true, - sourceKind: m.sourceKind, - })); - const confirmedKey = (m: { content: string; senderName: string }) => - `${m.senderName}${m.content}`; - const confirmedSet = new Set(messages.map(confirmedKey)); - const optimisticPending: ThreadEntry[] = pendingMessages - .filter((m) => !confirmedSet.has(confirmedKey(m))) - .map((m) => ({ - id: m.id, - senderName: m.senderName, - content: m.content, - timestamp: m.timestamp, - isFromMe: true, - isBotMessage: false, - sourceKind: m.sourceKind, - })); - const outputEntries: ThreadEntry[] = outputs - .filter((o) => !isInternalProtocolPayload(o.outputText)) - .map((o) => ({ - id: `out:${o.id}`, - senderName: o.role, - content: o.outputText, - timestamp: o.createdAt, - isFromMe: false, - isBotMessage: true, - sourceKind: 'agent_output', - verdict: o.verdict, - turnNumber: o.turnNumber, - })); - const agentMessages: ThreadEntry[] = [ - ...humanMessages, - ...optimisticPending, - ...cronMessages, - ...outputEntries, - ].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + const lastWatcher = + [...messages].reverse().find(isWatcherRoomMessage) ?? null; + const watcherCount = messages.filter(isWatcherRoomMessage).length; + const watcherMessages = messages.filter(isWatcherRoomMessage); + const agentMessages = buildRoomThreadEntries({ + messages, + outputs, + pendingMessages, + }); return (
['outputs'][number]; + +function message(overrides: Partial): RoomMessage { + return { + id: 'msg-1', + sender: 'user-1', + senderName: '눈쟁이', + content: 'hello', + timestamp: '2026-04-28T01:00:00.000Z', + isFromMe: false, + isBotMessage: false, + sourceKind: 'human', + ...overrides, + }; +} + +function output(overrides: Partial): RoomOutput { + return { + id: 1, + turnNumber: 1, + role: 'owner', + verdict: 'task_done', + createdAt: '2026-04-28T01:02:00.000Z', + outputText: 'TASK_DONE\n\nfinal output', + ...overrides, + }; +} + +describe('room thread entries', () => { + it('keeps regular bot messages so Discord-visible outputs appear in rooms', () => { + const entries = buildRoomThreadEntries({ + messages: [ + message({ id: 'human-1', content: '확인해줘' }), + message({ + id: 'bot-1', + sender: 'bot-1', + senderName: '오너', + content: 'TASK_DONE\n\nprod 배포 완료', + timestamp: '2026-04-28T01:01:00.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + ], + outputs: [], + }); + + expect(entries.map((entry) => entry.id)).toEqual(['human-1', 'bot-1']); + expect(entries.at(-1)).toMatchObject({ + senderName: '오너', + content: 'TASK_DONE\n\nprod 배포 완료', + sourceKind: 'bot', + }); + }); + + it('keeps watcher messages out of the main room thread', () => { + const watcher = message({ + id: 'watcher-1', + senderName: '리뷰어', + content: 'CI 완료: PR #1 Quality Check', + isBotMessage: true, + sourceKind: 'bot', + }); + + expect(isWatcherRoomMessage(watcher)).toBe(true); + expect( + buildRoomThreadEntries({ messages: [watcher], outputs: [] }), + ).toHaveLength(0); + }); + + it('deduplicates bot messages that are already present as paired outputs', () => { + const entries = buildRoomThreadEntries({ + messages: [ + message({ + id: 'bot-duplicate', + senderName: '오너', + content: 'TASK_DONE\n\nfinal output', + timestamp: '2026-04-28T01:02:30.000Z', + isBotMessage: true, + sourceKind: 'bot', + }), + ], + outputs: [output({ id: 7 })], + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: 'out:7', + sourceKind: 'agent_output', + turnNumber: 1, + }); + }); +}); diff --git a/apps/dashboard/src/roomThread.ts b/apps/dashboard/src/roomThread.ts new file mode 100644 index 0000000..31ad4d6 --- /dev/null +++ b/apps/dashboard/src/roomThread.ts @@ -0,0 +1,134 @@ +import type { DashboardRoomActivity } from './api'; + +type RoomMessage = DashboardRoomActivity['messages'][number]; +type RoomOutput = NonNullable< + DashboardRoomActivity['pairedTask'] +>['outputs'][number]; + +export type RoomThreadEntry = { + id: string; + senderName: string; + content: string; + timestamp: string; + isFromMe: boolean; + isBotMessage: boolean; + sourceKind: string; + verdict?: string | null; + turnNumber?: number; +}; + +const WATCHER_RE = + /^\s*(?:CI 완료|Build |Deploy |Lint |Release |\[CI\]|\[Watcher\]|GitHub Actions)/i; + +export function isInternalProtocolPayload( + content: string | null | undefined, +): boolean { + if (!content) return false; + if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true; + if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) { + return true; + } + return false; +} + +export function isWatcherRoomMessage(message: RoomMessage): boolean { + return message.sourceKind === 'bot' && WATCHER_RE.test(message.content); +} + +function isThreadChatMessage(message: RoomMessage): boolean { + if (isInternalProtocolPayload(message.content)) return false; + if (isWatcherRoomMessage(message)) return false; + return ( + message.sourceKind === 'human' || + message.sourceKind === 'ipc_injected_human' || + message.sourceKind === 'trusted_external_bot' || + message.sourceKind === 'bot' + ); +} + +function toMessageEntry(message: RoomMessage): RoomThreadEntry { + return { + id: message.id, + senderName: message.senderName, + content: message.content, + timestamp: message.timestamp, + isFromMe: message.isFromMe, + isBotMessage: message.isBotMessage, + sourceKind: message.sourceKind, + }; +} + +function toOutputEntry(output: RoomOutput): RoomThreadEntry | null { + if (isInternalProtocolPayload(output.outputText)) return null; + return { + id: `out:${output.id}`, + senderName: output.role, + content: output.outputText, + timestamp: output.createdAt, + isFromMe: false, + isBotMessage: true, + sourceKind: 'agent_output', + verdict: output.verdict, + turnNumber: output.turnNumber, + }; +} + +function messageKey(message: { content: string; senderName: string }): string { + return `${message.senderName}\u0001${message.content}`; +} + +function normalizeForDedupe(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function isDuplicateOutputMessage( + message: RoomThreadEntry, + outputEntries: RoomThreadEntry[], +): boolean { + if (message.sourceKind !== 'bot') return false; + const messageText = normalizeForDedupe(message.content); + if (!messageText) return false; + const messageTime = new Date(message.timestamp).getTime(); + + return outputEntries.some((output) => { + const outputText = normalizeForDedupe(output.content); + if (!outputText) return false; + const contentMatches = + outputText === messageText || + outputText.startsWith(messageText) || + messageText.startsWith(outputText); + if (!contentMatches) return false; + const outputTime = new Date(output.timestamp).getTime(); + if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) { + return true; + } + return Math.abs(outputTime - messageTime) <= 120_000; + }); +} + +export function buildRoomThreadEntries({ + messages, + outputs, + pendingMessages = [], +}: { + messages: RoomMessage[]; + outputs: RoomOutput[]; + pendingMessages?: RoomMessage[]; +}): RoomThreadEntry[] { + const confirmedSet = new Set(messages.map(messageKey)); + const outputEntries = outputs + .map(toOutputEntry) + .filter((entry): entry is RoomThreadEntry => Boolean(entry)); + const chatEntries = messages + .filter(isThreadChatMessage) + .map(toMessageEntry) + .filter((entry) => !isDuplicateOutputMessage(entry, outputEntries)); + const optimisticPending = pendingMessages + .filter((message) => !confirmedSet.has(messageKey(message))) + .filter((message) => !isInternalProtocolPayload(message.content)) + .map(toMessageEntry); + + return [...chatEntries, ...optimisticPending, ...outputEntries].sort((a, b) => + a.timestamp.localeCompare(b.timestamp), + ); +} diff --git a/vitest.config.ts b/vitest.config.ts index 7ced00e..ec3bbe6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ testTimeout: 15_000, include: [ 'src/**/*.test.ts', + 'apps/dashboard/src/**/*.test.ts', 'setup/**/*.test.ts', 'runners/**/test/**/*.test.ts', ],