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:
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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user