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.
This commit is contained in:
@@ -57,6 +57,11 @@ import {
|
|||||||
type DashboardFreshness,
|
type DashboardFreshness,
|
||||||
type DashboardView,
|
type DashboardView,
|
||||||
} from './DashboardNav';
|
} from './DashboardNav';
|
||||||
|
import {
|
||||||
|
buildRoomThreadEntries,
|
||||||
|
isInternalProtocolPayload,
|
||||||
|
isWatcherRoomMessage,
|
||||||
|
} from './roomThread';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
interface DashboardState {
|
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 {
|
function senderRoleClass(value: string | null | undefined): string {
|
||||||
const v = (value ?? '').toLowerCase();
|
const v = (value ?? '').toLowerCase();
|
||||||
if (v.includes('오너') || v.includes('owner')) return 'role-owner';
|
if (v.includes('오너') || v.includes('owner')) return 'role-owner';
|
||||||
@@ -2845,85 +2839,15 @@ function RoomCardV2({
|
|||||||
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
|
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
|
||||||
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
|
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
|
||||||
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
|
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
|
||||||
const WATCHER_RE =
|
const lastWatcher =
|
||||||
/^\s*(?:CI 완료|Build |Deploy |Lint |Release |\[CI\]|\[Watcher\]|GitHub Actions)/i;
|
[...messages].reverse().find(isWatcherRoomMessage) ?? null;
|
||||||
const isWatcherMsg = (m: (typeof messages)[number]) =>
|
const watcherCount = messages.filter(isWatcherRoomMessage).length;
|
||||||
m.sourceKind === 'bot' && WATCHER_RE.test(m.content);
|
const watcherMessages = messages.filter(isWatcherRoomMessage);
|
||||||
const isCronTriggerMsg = (m: (typeof messages)[number]) =>
|
const agentMessages = buildRoomThreadEntries({
|
||||||
m.sourceKind === 'trusted_external_bot';
|
messages,
|
||||||
const lastWatcher = [...messages].reverse().find(isWatcherMsg) ?? null;
|
outputs,
|
||||||
const watcherCount = messages.filter(isWatcherMsg).length;
|
pendingMessages,
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|||||||
100
apps/dashboard/src/roomThread.test.ts
Normal file
100
apps/dashboard/src/roomThread.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { DashboardRoomActivity } from './api';
|
||||||
|
import { buildRoomThreadEntries, isWatcherRoomMessage } from './roomThread';
|
||||||
|
|
||||||
|
type RoomMessage = DashboardRoomActivity['messages'][number];
|
||||||
|
type RoomOutput = NonNullable<
|
||||||
|
DashboardRoomActivity['pairedTask']
|
||||||
|
>['outputs'][number];
|
||||||
|
|
||||||
|
function message(overrides: Partial<RoomMessage>): 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>): 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
134
apps/dashboard/src/roomThread.ts
Normal file
134
apps/dashboard/src/roomThread.ts
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
include: [
|
include: [
|
||||||
'src/**/*.test.ts',
|
'src/**/*.test.ts',
|
||||||
|
'apps/dashboard/src/**/*.test.ts',
|
||||||
'setup/**/*.test.ts',
|
'setup/**/*.test.ts',
|
||||||
'runners/**/test/**/*.test.ts',
|
'runners/**/test/**/*.test.ts',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user