Use canonical turn progress for dashboard rooms

This commit is contained in:
ejclaw
2026-04-28 22:53:00 +09:00
parent b83de973f8
commit 85e04e8a67
5 changed files with 22 additions and 124 deletions

View File

@@ -356,7 +356,7 @@ describe('web dashboard room activity data', () => {
]);
});
it('uses Discord live progress messages without exposing them as recent chat', () => {
it('uses canonical turn progress without exposing status messages as recent chat', () => {
const task = makePairedTask({
id: 'paired-room-progress',
chat_jid: 'dc:ops',
@@ -377,7 +377,7 @@ describe('web dashboard room activity data', () => {
updated_at: '2026-04-26T06:10:00.000Z',
completed_at: null,
last_error: null,
progress_text: 'internal progress that Discord never showed',
progress_text: 'building mobile parity',
progress_updated_at: '2026-04-26T06:07:00.000Z',
};
const statusPrefix = '';
@@ -393,25 +393,12 @@ describe('web dashboard room activity data', () => {
is_bot_message: false,
message_source_kind: 'human',
},
];
const progressMessages: NewMessage[] = [
{
id: 'msg-stale-progress',
id: 'msg-status-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: '오너',
content: `${statusPrefix}stale progress`,
timestamp: '2026-04-26T05:59:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
},
{
id: 'msg-live-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: 'clone-코덱스',
content: `${statusPrefix}building mobile parity\n\n12s`,
content: `${statusPrefix}status noise that belongs to display only\n\n12s`,
timestamp: '2026-04-26T06:08:00.000Z',
is_from_me: true,
is_bot_message: true,
@@ -436,12 +423,11 @@ describe('web dashboard room activity data', () => {
attempts: [],
outputs: [],
messages,
progressMessages,
});
expect(activity.pairedTask?.currentTurn).toMatchObject({
progressText: 'building mobile parity',
progressUpdatedAt: '2026-04-26T06:08:00.000Z',
progressUpdatedAt: '2026-04-26T06:07:00.000Z',
});
expect(activity.messages).toEqual([
expect.objectContaining({
@@ -451,7 +437,7 @@ describe('web dashboard room activity data', () => {
]);
});
it('does not show internal turn progress without a Discord-visible status message', () => {
it('does not show active turn placeholders when canonical progress is absent', () => {
const task = makePairedTask({
id: 'paired-progress-internal',
chat_jid: 'dc:ops',
@@ -472,8 +458,8 @@ describe('web dashboard room activity data', () => {
updated_at: '2026-04-26T06:10:00.000Z',
completed_at: null,
last_error: null,
progress_text: 'site-only internal progress',
progress_updated_at: '2026-04-26T06:09:00.000Z',
progress_text: null,
progress_updated_at: null,
};
const activity = buildWebDashboardRoomActivity({
@@ -493,19 +479,6 @@ describe('web dashboard room activity data', () => {
attempts: [],
outputs: [],
messages: [],
progressMessages: [
{
id: 'spoofed-progress',
chat_jid: 'dc:ops',
sender: 'user-1',
sender_name: '눈쟁이',
content: 'human spoofed progress',
timestamp: '2026-04-26T06:09:30.000Z',
is_from_me: false,
is_bot_message: false,
message_source_kind: 'human',
},
],
});
expect(activity.pairedTask?.currentTurn).toMatchObject({

View File

@@ -492,78 +492,25 @@ function sanitizeRoomMessage(
};
}
/**
* SSOT for live progress: scan the `messages` table for the most recent
* TASK_STATUS_MESSAGE_PREFIX-prefixed message that belongs to the current
* turn (sender role match + timestamp >= turn.created_at). The Discord
* bridge ingests every edit of the bot's live message back into messages,
* so this single source replaces the legacy paired_turns.progress_text
* column for derivation.
*/
const TASK_STATUS_PREFIX = '';
function isTaskStatusMessage(message: NewMessage): boolean {
return (message.content ?? '').startsWith(TASK_STATUS_PREFIX);
}
function turnRoleFromSenderName(name: string | null | undefined): string {
const v = (name ?? '').toLowerCase();
if (v.includes('오너') || v.includes('owner')) return 'owner';
if (v.includes('리뷰어') || v.includes('reviewer')) return 'reviewer';
if (v.includes('중재자') || v.includes('arbiter')) return 'arbiter';
return 'human';
}
function isBotProgressSource(message: NewMessage): boolean {
return (
!!message.is_bot_message ||
!!message.is_from_me ||
message.message_source_kind === 'bot' ||
message.message_source_kind === 'ipc_injected_bot' ||
message.message_source_kind === 'trusted_external_bot'
);
}
function deriveLiveProgress(
turnRole: string,
turnCreatedAt: string,
messages: NewMessage[],
): { progressText: string | null; progressUpdatedAt: string | null } {
const startMs = new Date(turnCreatedAt).getTime();
const targetRole = turnRoleFromSenderName(turnRole);
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
const content = m.content ?? '';
if (!content.startsWith(TASK_STATUS_PREFIX)) continue;
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
if (Number.isFinite(startMs) && ts < startMs) continue;
const role = turnRoleFromSenderName(m.sender_name);
if (role !== targetRole && (role !== 'human' || !isBotProgressSource(m))) {
continue;
}
const body = content.slice(TASK_STATUS_PREFIX.length);
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
if (!stripped) continue;
return { progressText: stripped, progressUpdatedAt: m.timestamp };
}
return { progressText: null, progressUpdatedAt: null };
}
function sanitizeRoomTurn(
turn: PairedTurnRecord,
attempt: PairedTurnAttemptRecord | null,
messages: NewMessage[] = [],
): WebDashboardRoomTurn {
const role = attempt?.role ?? turn.role;
const createdAt = attempt?.created_at ?? turn.created_at;
const completedAt = attempt?.completed_at ?? turn.completed_at;
// Dashboard progress must match Discord-visible progress. The legacy
// paired_turns.progress_text column is written before Discord delivery, so
// using it here can create site-only progress that never appeared in Discord.
const live =
completedAt === null
? deriveLiveProgress(role, createdAt, messages)
const canonicalProgress =
completedAt === null && turn.progress_text?.trim()
? {
progressText: buildRoomBody(turn.progress_text),
progressUpdatedAt: turn.progress_updated_at ?? turn.updated_at,
}
: { progressText: null, progressUpdatedAt: null };
return {
@@ -582,8 +529,8 @@ function sanitizeRoomTurn(
(attempt?.last_error ?? turn.last_error)
? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '')
: null,
progressText: live.progressText,
progressUpdatedAt: live.progressUpdatedAt,
progressText: canonicalProgress.progressText,
progressUpdatedAt: canonicalProgress.progressUpdatedAt,
};
}
@@ -612,7 +559,6 @@ export function buildWebDashboardRoomActivity(args: {
attempts: PairedTurnAttemptRecord[];
outputs: PairedTurnOutput[];
messages: NewMessage[];
progressMessages?: NewMessage[];
outputLimit?: number;
}): WebDashboardRoomActivity {
const latestAttemptByTurnId = new Map<string, PairedTurnAttemptRecord>();
@@ -655,11 +601,7 @@ export function buildWebDashboardRoomActivity(args: {
roundTripCount: args.pairedTask.round_trip_count,
updatedAt: args.pairedTask.updated_at,
currentTurn: currentTurn
? sanitizeRoomTurn(
currentTurn,
currentAttempt,
args.progressMessages ?? args.messages,
)
? sanitizeRoomTurn(currentTurn, currentAttempt)
: null,
outputs: args.outputs
.slice(-outputLimit)

View File

@@ -106,6 +106,8 @@ describe('web dashboard room routes', () => {
updated_at: '2026-04-26T05:21:00.000Z',
completed_at: null,
last_error: null,
progress_text: 'checking current output',
progress_updated_at: '2026-04-26T05:20:00.000Z',
},
];
const attempts: PairedTurnAttemptRecord[] = [
@@ -154,20 +156,6 @@ describe('web dashboard room routes', () => {
message_source_kind: 'human',
},
];
const progressMessages: NewMessage[] = [
...messages,
{
id: 'msg-progress',
chat_jid: 'dc:ops',
sender: 'reviewer-bot',
sender_name: '리뷰어',
content: 'checking current output\n\n9s',
timestamp: '2026-04-26T05:20:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
},
];
const requestedMessageLimits: number[] = [];
const response = handleRoomTimelineRoute({
url: new URL(
@@ -182,7 +170,7 @@ describe('web dashboard room routes', () => {
loadPairedTurnOutputs: () => outputs,
loadRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20);
return limit && limit > 8 ? progressMessages : messages;
return messages;
},
}),
});
@@ -202,7 +190,7 @@ describe('web dashboard room routes', () => {
expect(body.pairedTask.outputs).toMatchObject([
{ outputText: 'owner final output' },
]);
expect(requestedMessageLimits).toEqual([8, 40]);
expect(requestedMessageLimits).toEqual([8]);
expect(body.messages[0]).toMatchObject({
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
senderName: '눈쟁이',

View File

@@ -98,7 +98,6 @@ function buildRoomActivity(
attempts,
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
messages: deps.loadRecentChatMessages(entry.jid, 8),
progressMessages: deps.loadRecentChatMessages(entry.jid, 40),
});
}
@@ -129,9 +128,6 @@ export function buildRoomsTimelineResult(
for (const [jid, { snapshot, entry }] of uniqueByJid) {
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
const messages = deps.loadRecentChatMessages(jid, 8);
const progressMessages = pairedTask
? deps.loadRecentChatMessages(jid, 40)
: messages;
const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8);
if (!pairedTask && messages.length === 0 && outputs.length === 0) continue;
@@ -147,7 +143,6 @@ export function buildRoomsTimelineResult(
attempts: [],
outputs,
messages,
progressMessages,
outputLimit: 8,
});
}

View File

@@ -337,7 +337,7 @@ describe('web dashboard server handler', () => {
id: 'paired-room-1',
roundTripCount: 2,
});
expect(requestedMessageLimits).toEqual([8, 40]);
expect(requestedMessageLimits).toEqual([8]);
expect(body.messages).toMatchObject([
{ content: '진행 어디까지야?', senderName: '눈쟁이' },
]);