fix: use work items as dashboard outbound SSOT
This commit is contained in:
@@ -136,7 +136,7 @@ describe('DiscordChannel structured output', () => {
|
|||||||
};
|
};
|
||||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
await channel.sendMessage(
|
const result = await channel.sendMessage(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ejclaw: {
|
ejclaw: {
|
||||||
@@ -154,6 +154,11 @@ describe('DiscordChannel structured output', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
primaryMessageId: 'discord-message-1',
|
||||||
|
messageIds: ['discord-message-1'],
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
files: [
|
files: [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { logger } from '../logger.js';
|
|||||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||||
import { formatOutbound } from '../router.js';
|
import { formatOutbound } from '../router.js';
|
||||||
import { hasReviewerLease } from '../service-routing.js';
|
import { hasReviewerLease } from '../service-routing.js';
|
||||||
import type { SendMessageOptions } from '../types.js';
|
import type { SendMessageOptions, SendMessageResult } from '../types.js';
|
||||||
import { prepareDiscordOutbound } from './discord-outbound.js';
|
import { prepareDiscordOutbound } from './discord-outbound.js';
|
||||||
|
|
||||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||||
@@ -408,7 +408,7 @@ export class DiscordChannel implements Channel {
|
|||||||
jid: string,
|
jid: string,
|
||||||
text: string,
|
text: string,
|
||||||
options: SendMessageOptions = {},
|
options: SendMessageOptions = {},
|
||||||
): Promise<void> {
|
): Promise<SendMessageResult> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Discord client not initialized');
|
throw new Error('Discord client not initialized');
|
||||||
}
|
}
|
||||||
@@ -429,7 +429,7 @@ export class DiscordChannel implements Channel {
|
|||||||
{ jid, channelName: this.name },
|
{ jid, channelName: this.name },
|
||||||
'Skipping silent structured Discord outbound message',
|
'Skipping silent structured Discord outbound message',
|
||||||
);
|
);
|
||||||
return;
|
return { primaryMessageId: null, messageIds: [], visible: false };
|
||||||
}
|
}
|
||||||
const validation = validateOutboundAttachments(outbound.attachments, {
|
const validation = validateOutboundAttachments(outbound.attachments, {
|
||||||
baseDirs: options.attachmentBaseDirs,
|
baseDirs: options.attachmentBaseDirs,
|
||||||
@@ -478,7 +478,7 @@ export class DiscordChannel implements Channel {
|
|||||||
{ jid, channelName: this.name },
|
{ jid, channelName: this.name },
|
||||||
'Skipping empty Discord outbound message',
|
'Skipping empty Discord outbound message',
|
||||||
);
|
);
|
||||||
return;
|
return { primaryMessageId: null, messageIds: [], visible: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split files into batches of MAX_ATTACHMENTS
|
// Split files into batches of MAX_ATTACHMENTS
|
||||||
@@ -546,6 +546,11 @@ export class DiscordChannel implements Channel {
|
|||||||
},
|
},
|
||||||
'Discord message sent',
|
'Discord message sent',
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
primaryMessageId: sentMessageIds[0] ?? null,
|
||||||
|
messageIds: sentMessageIds,
|
||||||
|
visible: chunkCount > 0,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ jid, channelName: this.name, err },
|
{ jid, channelName: this.name, err },
|
||||||
|
|||||||
51
src/db-work-items.test.ts
Normal file
51
src/db-work-items.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
_initTestDatabase,
|
||||||
|
createProducedWorkItem,
|
||||||
|
getOpenWorkItem,
|
||||||
|
getRecentDeliveredWorkItemsForChat,
|
||||||
|
markWorkItemDelivered,
|
||||||
|
} from './db.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_initTestDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('work item canonical output queue', () => {
|
||||||
|
it('supersedes stale open work items before creating a newer canonical output', () => {
|
||||||
|
const stale = createProducedWorkItem({
|
||||||
|
group_folder: 'discord_test',
|
||||||
|
chat_jid: 'dc:supersede',
|
||||||
|
agent_type: 'codex',
|
||||||
|
service_id: 'codex-main',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
start_seq: 1,
|
||||||
|
end_seq: 2,
|
||||||
|
result_payload: 'stale owner output',
|
||||||
|
});
|
||||||
|
const fresh = createProducedWorkItem({
|
||||||
|
group_folder: 'discord_test',
|
||||||
|
chat_jid: 'dc:supersede',
|
||||||
|
agent_type: 'codex',
|
||||||
|
service_id: 'codex-main',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
start_seq: 3,
|
||||||
|
end_seq: 4,
|
||||||
|
result_payload: 'fresh owner output',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fresh.id).not.toBe(stale.id);
|
||||||
|
expect(getOpenWorkItem('dc:supersede', 'codex', 'codex-main')?.id).toBe(
|
||||||
|
fresh.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
markWorkItemDelivered(fresh.id, 'discord-message-id');
|
||||||
|
expect(getRecentDeliveredWorkItemsForChat('dc:supersede', 10)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: fresh.id,
|
||||||
|
result_payload: 'fresh owner output',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
getNewMessagesBySeq,
|
getNewMessagesBySeq,
|
||||||
getOpenWorkItem,
|
getOpenWorkItem,
|
||||||
getOpenWorkItemForChat,
|
getOpenWorkItemForChat,
|
||||||
|
getRecentDeliveredWorkItemsForChat,
|
||||||
getRecentChatMessages,
|
getRecentChatMessages,
|
||||||
getRecentChatMessagesBatch,
|
getRecentChatMessagesBatch,
|
||||||
hasMessage,
|
hasMessage,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
createProducedWorkItemInDatabase,
|
createProducedWorkItemInDatabase,
|
||||||
getOpenWorkItemForChatFromDatabase,
|
getOpenWorkItemForChatFromDatabase,
|
||||||
getOpenWorkItemFromDatabase,
|
getOpenWorkItemFromDatabase,
|
||||||
|
getRecentDeliveredWorkItemsForChatFromDatabase,
|
||||||
markWorkItemDeliveredInDatabase,
|
markWorkItemDeliveredInDatabase,
|
||||||
markWorkItemDeliveryRetryInDatabase,
|
markWorkItemDeliveryRetryInDatabase,
|
||||||
} from './work-items.js';
|
} from './work-items.js';
|
||||||
@@ -283,6 +284,17 @@ export function getOpenWorkItemForChat(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRecentDeliveredWorkItemsForChat(
|
||||||
|
chatJid: string,
|
||||||
|
limit: number = 8,
|
||||||
|
): WorkItem[] {
|
||||||
|
return getRecentDeliveredWorkItemsForChatFromDatabase(
|
||||||
|
requireDatabase(),
|
||||||
|
chatJid,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createProducedWorkItem(
|
export function createProducedWorkItem(
|
||||||
input: CreateProducedWorkItemInput,
|
input: CreateProducedWorkItemInput,
|
||||||
): WorkItem {
|
): WorkItem {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from '../role-service-shadow.js';
|
} from '../role-service-shadow.js';
|
||||||
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
|
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
|
||||||
|
|
||||||
|
const SUPERSEDED_WORK_ITEM_ERROR = 'superseded_by_newer_canonical_output';
|
||||||
|
|
||||||
export interface WorkItem {
|
export interface WorkItem {
|
||||||
id: number;
|
id: number;
|
||||||
group_folder: string;
|
group_folder: string;
|
||||||
@@ -239,6 +241,57 @@ export function getOpenWorkItemForChatFromDatabase(
|
|||||||
return row ? hydrateWorkItemRow(row) : undefined;
|
return row ? hydrateWorkItemRow(row) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRecentDeliveredWorkItemsForChatFromDatabase(
|
||||||
|
database: Database,
|
||||||
|
chatJid: string,
|
||||||
|
limit: number = 8,
|
||||||
|
): WorkItem[] {
|
||||||
|
const rows = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT *
|
||||||
|
FROM work_items
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND status = 'delivered'
|
||||||
|
AND (last_error IS NULL OR last_error <> ?)
|
||||||
|
ORDER BY COALESCE(delivered_at, updated_at, created_at) DESC, id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(chatJid, SUPERSEDED_WORK_ITEM_ERROR, limit) as StoredWorkItemRow[];
|
||||||
|
return rows.reverse().map(hydrateWorkItemRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supersedeOpenWorkItemsForCanonicalKey(
|
||||||
|
database: Database,
|
||||||
|
input: CreateProducedWorkItemInput,
|
||||||
|
agentType: AgentType,
|
||||||
|
serviceId: string,
|
||||||
|
now: string,
|
||||||
|
): void {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE work_items
|
||||||
|
SET status = 'delivered',
|
||||||
|
delivered_at = ?,
|
||||||
|
delivery_message_id = NULL,
|
||||||
|
last_error = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND agent_type = ?
|
||||||
|
AND IFNULL(service_id, '') = IFNULL(?, '')
|
||||||
|
AND IFNULL(delivery_role, '') = IFNULL(?, '')
|
||||||
|
AND status IN ('produced', 'delivery_retry')`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
now,
|
||||||
|
SUPERSEDED_WORK_ITEM_ERROR,
|
||||||
|
now,
|
||||||
|
input.chat_jid,
|
||||||
|
agentType,
|
||||||
|
serviceId,
|
||||||
|
input.delivery_role ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createProducedWorkItemInDatabase(
|
export function createProducedWorkItemInDatabase(
|
||||||
database: Database,
|
database: Database,
|
||||||
input: CreateProducedWorkItemInput,
|
input: CreateProducedWorkItemInput,
|
||||||
@@ -250,46 +303,57 @@ export function createProducedWorkItemInDatabase(
|
|||||||
deliveryRole: input.delivery_role,
|
deliveryRole: input.delivery_role,
|
||||||
serviceId: input.service_id,
|
serviceId: input.service_id,
|
||||||
});
|
});
|
||||||
database
|
return database.transaction(() => {
|
||||||
.prepare(
|
supersedeOpenWorkItemsForCanonicalKey(
|
||||||
`INSERT INTO work_items (
|
database,
|
||||||
group_folder,
|
input,
|
||||||
chat_jid,
|
|
||||||
agent_type,
|
|
||||||
service_id,
|
|
||||||
delivery_role,
|
|
||||||
status,
|
|
||||||
start_seq,
|
|
||||||
end_seq,
|
|
||||||
result_payload,
|
|
||||||
attachment_payload,
|
|
||||||
delivery_attempts,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
input.group_folder,
|
|
||||||
input.chat_jid,
|
|
||||||
agentType,
|
agentType,
|
||||||
serviceId,
|
serviceId,
|
||||||
input.delivery_role ?? null,
|
|
||||||
input.start_seq,
|
|
||||||
input.end_seq,
|
|
||||||
input.result_payload,
|
|
||||||
serializeAttachmentPayload(input.attachments),
|
|
||||||
now,
|
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastId = (
|
|
||||||
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
|
|
||||||
).id;
|
|
||||||
return hydrateWorkItemRow(
|
|
||||||
database
|
database
|
||||||
.prepare('SELECT * FROM work_items WHERE id = ?')
|
.prepare(
|
||||||
.get(lastId) as StoredWorkItemRow,
|
`INSERT INTO work_items (
|
||||||
);
|
group_folder,
|
||||||
|
chat_jid,
|
||||||
|
agent_type,
|
||||||
|
service_id,
|
||||||
|
delivery_role,
|
||||||
|
status,
|
||||||
|
start_seq,
|
||||||
|
end_seq,
|
||||||
|
result_payload,
|
||||||
|
attachment_payload,
|
||||||
|
delivery_attempts,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
input.group_folder,
|
||||||
|
input.chat_jid,
|
||||||
|
agentType,
|
||||||
|
serviceId,
|
||||||
|
input.delivery_role ?? null,
|
||||||
|
input.start_seq,
|
||||||
|
input.end_seq,
|
||||||
|
input.result_payload,
|
||||||
|
serializeAttachmentPayload(input.attachments),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastId = (
|
||||||
|
database.prepare('SELECT last_insert_rowid() as id').get() as {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
).id;
|
||||||
|
return hydrateWorkItemRow(
|
||||||
|
database
|
||||||
|
.prepare('SELECT * FROM work_items WHERE id = ?')
|
||||||
|
.get(lastId) as StoredWorkItemRow,
|
||||||
|
);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markWorkItemDeliveredInDatabase(
|
export function markWorkItemDeliveredInDatabase(
|
||||||
|
|||||||
@@ -102,22 +102,20 @@ export async function deliverOpenWorkItem(args: {
|
|||||||
}),
|
}),
|
||||||
'Attempting to deliver produced work item as a new message',
|
'Attempting to deliver produced work item as a new message',
|
||||||
);
|
);
|
||||||
if (hasAttachments) {
|
const sendResult = hasAttachments
|
||||||
await args.channel.sendMessage(
|
? await args.channel.sendMessage(
|
||||||
args.item.chat_jid,
|
args.item.chat_jid,
|
||||||
args.item.result_payload,
|
args.item.result_payload,
|
||||||
{
|
{
|
||||||
attachmentBaseDirs: args.attachmentBaseDirs,
|
attachmentBaseDirs: args.attachmentBaseDirs,
|
||||||
attachments,
|
attachments,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
} else {
|
: await args.channel.sendMessage(
|
||||||
await args.channel.sendMessage(
|
args.item.chat_jid,
|
||||||
args.item.chat_jid,
|
args.item.result_payload,
|
||||||
args.item.result_payload,
|
);
|
||||||
);
|
markWorkItemDelivered(args.item.id, sendResult?.primaryMessageId ?? null);
|
||||||
}
|
|
||||||
markWorkItemDelivered(args.item.id);
|
|
||||||
args.openContinuation(args.item.chat_jid);
|
args.openContinuation(args.item.chat_jid);
|
||||||
args.log.info(
|
args.log.info(
|
||||||
buildDeliveryLogContext(args.channel, args.item, {
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface SendMessageOptions {
|
|||||||
attachmentBaseDirs?: string[];
|
attachmentBaseDirs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResult {
|
||||||
|
primaryMessageId: string | null;
|
||||||
|
messageIds: string[];
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
@@ -255,7 +261,7 @@ export interface Channel {
|
|||||||
jid: string,
|
jid: string,
|
||||||
text: string,
|
text: string,
|
||||||
options?: SendMessageOptions,
|
options?: SendMessageOptions,
|
||||||
): Promise<void>;
|
): Promise<SendMessageResult | void>;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
ownsJid(jid: string): boolean;
|
ownsJid(jid: string): boolean;
|
||||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { StatusSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot } from './status-dashboard.js';
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type {
|
||||||
|
PairedTurnAttemptRecord,
|
||||||
|
PairedTurnRecord,
|
||||||
|
WorkItem,
|
||||||
|
} from './db.js';
|
||||||
import type {
|
import type {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
@@ -65,6 +69,29 @@ function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDeliveredWorkItem(overrides: Partial<WorkItem> = {}): WorkItem {
|
||||||
|
return {
|
||||||
|
id: 100,
|
||||||
|
group_folder: 'ops',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
agent_type: 'codex',
|
||||||
|
service_id: 'codex-main',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
status: 'delivered',
|
||||||
|
start_seq: null,
|
||||||
|
end_seq: null,
|
||||||
|
result_payload: 'TASK_DONE\n\ncanonical delivered output',
|
||||||
|
attachments: [],
|
||||||
|
delivery_attempts: 1,
|
||||||
|
delivery_message_id: 'discord-msg-100',
|
||||||
|
last_error: null,
|
||||||
|
created_at: '2026-04-26T05:30:00.000Z',
|
||||||
|
updated_at: '2026-04-26T05:30:10.000Z',
|
||||||
|
delivered_at: '2026-04-26T05:30:10.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('web dashboard data', () => {
|
describe('web dashboard data', () => {
|
||||||
it('builds overview counts from status snapshots and scheduled tasks', () => {
|
it('builds overview counts from status snapshots and scheduled tasks', () => {
|
||||||
const snapshots: StatusSnapshot[] = [
|
const snapshots: StatusSnapshot[] = [
|
||||||
@@ -356,6 +383,110 @@ describe('web dashboard room activity data', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses delivered work items as the visible outbound source when supplied', () => {
|
||||||
|
const task = makePairedTask({
|
||||||
|
id: 'paired-canonical-outbound',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
status: 'in_review',
|
||||||
|
updated_at: '2026-04-26T05:30:00.000Z',
|
||||||
|
});
|
||||||
|
const output: PairedTurnOutput = {
|
||||||
|
id: 22,
|
||||||
|
task_id: task.id,
|
||||||
|
turn_number: 2,
|
||||||
|
role: 'owner',
|
||||||
|
output_text: 'TASK_DONE\n\nexecution artifact only',
|
||||||
|
verdict: 'task_done',
|
||||||
|
created_at: '2026-04-26T05:30:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activity = buildWebDashboardRoomActivity({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
entry: {
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'inactive',
|
||||||
|
elapsedMs: null,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 0,
|
||||||
|
},
|
||||||
|
pairedTask: task,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [output],
|
||||||
|
outboundItems: [makeDeliveredWorkItem()],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'discord-msg-100',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
sender: 'bot-owner',
|
||||||
|
sender_name: '오너',
|
||||||
|
content: 'TASK_DONE\n\ncanonical delivered output',
|
||||||
|
timestamp: '2026-04-26T05:30:10.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
is_bot_message: true,
|
||||||
|
message_source_kind: 'bot',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.pairedTask?.outputs).toEqual([]);
|
||||||
|
expect(activity.messages).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'work:100',
|
||||||
|
senderName: 'owner',
|
||||||
|
content: 'TASK_DONE\n\ncanonical delivered output',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges canonical outbound and recent messages in chronological order', () => {
|
||||||
|
const activity = buildWebDashboardRoomActivity({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
entry: {
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'inactive',
|
||||||
|
elapsedMs: null,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 0,
|
||||||
|
},
|
||||||
|
pairedTask: null,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [],
|
||||||
|
outboundItems: [
|
||||||
|
makeDeliveredWorkItem({
|
||||||
|
id: 101,
|
||||||
|
delivered_at: '2026-04-26T05:30:00.000Z',
|
||||||
|
result_payload: 'owner canonical',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'human-after',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
sender: 'user-1',
|
||||||
|
sender_name: '눈쟁이',
|
||||||
|
content: 'human after',
|
||||||
|
timestamp: '2026-04-26T05:31:00.000Z',
|
||||||
|
is_from_me: false,
|
||||||
|
is_bot_message: false,
|
||||||
|
message_source_kind: 'human',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.messages.map((message) => message.id)).toEqual([
|
||||||
|
'work:101',
|
||||||
|
'human-after',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses canonical turn progress without exposing status messages as recent chat', () => {
|
it('uses canonical turn progress without exposing status messages as recent chat', () => {
|
||||||
const task = makePairedTask({
|
const task = makePairedTask({
|
||||||
id: 'paired-room-progress',
|
id: 'paired-room-progress',
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import {
|
|||||||
normalizeEjclawStructuredOutput,
|
normalizeEjclawStructuredOutput,
|
||||||
} from './agent-protocol.js';
|
} from './agent-protocol.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type {
|
||||||
|
PairedTurnAttemptRecord,
|
||||||
|
PairedTurnRecord,
|
||||||
|
WorkItem,
|
||||||
|
} from './db.js';
|
||||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
||||||
import type {
|
import type {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
@@ -492,6 +496,95 @@ function sanitizeRoomMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roleSenderName(role: WorkItem['delivery_role']): string {
|
||||||
|
return role ?? 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCanonicalOutboundMessage(
|
||||||
|
item: WorkItem,
|
||||||
|
): WebDashboardRoomMessage | null {
|
||||||
|
if (item.status !== 'delivered') return null;
|
||||||
|
const normalized = normalizeStructuredVisibleContent(item.result_payload);
|
||||||
|
if (normalized.text === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `work:${item.id}`,
|
||||||
|
sender: `work-item:${item.delivery_role ?? 'system'}`,
|
||||||
|
senderName: roleSenderName(item.delivery_role ?? null),
|
||||||
|
content: buildRoomBody(normalized.text),
|
||||||
|
attachments:
|
||||||
|
normalized.attachments.length > 0
|
||||||
|
? normalized.attachments
|
||||||
|
: (item.attachments ?? []),
|
||||||
|
timestamp: item.delivered_at ?? item.updated_at,
|
||||||
|
isFromMe: false,
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForOutboundDedupe(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function senderMatchesCanonicalOutbound(
|
||||||
|
messageSender: string,
|
||||||
|
outboundSender: string,
|
||||||
|
): boolean {
|
||||||
|
const message = messageSender.trim().toLowerCase();
|
||||||
|
const outbound = outboundSender.trim().toLowerCase();
|
||||||
|
if (message === outbound) return true;
|
||||||
|
const aliases: Record<string, string[]> = {
|
||||||
|
owner: ['owner', '오너'],
|
||||||
|
reviewer: ['reviewer', '리뷰어'],
|
||||||
|
arbiter: ['arbiter', '중재자'],
|
||||||
|
system: ['system', '시스템'],
|
||||||
|
};
|
||||||
|
return (aliases[outbound] ?? [outbound]).includes(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicateOfCanonicalOutbound(
|
||||||
|
message: WebDashboardRoomMessage,
|
||||||
|
canonical: WebDashboardRoomMessage[],
|
||||||
|
): boolean {
|
||||||
|
if (!message.isBotMessage) return false;
|
||||||
|
const messageText = normalizeForOutboundDedupe(message.content);
|
||||||
|
if (!messageText) return false;
|
||||||
|
const messageTime = new Date(message.timestamp).getTime();
|
||||||
|
|
||||||
|
return canonical.some((outbound) => {
|
||||||
|
if (
|
||||||
|
!senderMatchesCanonicalOutbound(message.senderName, outbound.senderName)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const outboundText = normalizeForOutboundDedupe(outbound.content);
|
||||||
|
if (!outboundText) return false;
|
||||||
|
const contentMatches =
|
||||||
|
outboundText === messageText ||
|
||||||
|
outboundText.startsWith(messageText) ||
|
||||||
|
messageText.startsWith(outboundText) ||
|
||||||
|
outboundText.includes(messageText) ||
|
||||||
|
messageText.includes(outboundText);
|
||||||
|
if (!contentMatches) return false;
|
||||||
|
const outboundTime = new Date(outbound.timestamp).getTime();
|
||||||
|
if (!Number.isFinite(messageTime) || !Number.isFinite(outboundTime)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Math.abs(outboundTime - messageTime) <= 120_000;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoomMessagesByTimestamp(
|
||||||
|
a: WebDashboardRoomMessage,
|
||||||
|
b: WebDashboardRoomMessage,
|
||||||
|
): number {
|
||||||
|
const aTime = new Date(a.timestamp).getTime();
|
||||||
|
const bTime = new Date(b.timestamp).getTime();
|
||||||
|
if (!Number.isFinite(aTime) || !Number.isFinite(bTime)) return 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
}
|
||||||
|
|
||||||
const TASK_STATUS_PREFIX = '';
|
const TASK_STATUS_PREFIX = '';
|
||||||
|
|
||||||
function isTaskStatusMessage(message: NewMessage): boolean {
|
function isTaskStatusMessage(message: NewMessage): boolean {
|
||||||
@@ -558,6 +651,7 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
turns: PairedTurnRecord[];
|
turns: PairedTurnRecord[];
|
||||||
attempts: PairedTurnAttemptRecord[];
|
attempts: PairedTurnAttemptRecord[];
|
||||||
outputs: PairedTurnOutput[];
|
outputs: PairedTurnOutput[];
|
||||||
|
outboundItems?: WorkItem[];
|
||||||
messages: NewMessage[];
|
messages: NewMessage[];
|
||||||
outputLimit?: number;
|
outputLimit?: number;
|
||||||
}): WebDashboardRoomActivity {
|
}): WebDashboardRoomActivity {
|
||||||
@@ -576,6 +670,24 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
|
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const outputLimit = args.outputLimit ?? 4;
|
const outputLimit = args.outputLimit ?? 4;
|
||||||
|
const canonicalOutboundMessages = (args.outboundItems ?? [])
|
||||||
|
.map(sanitizeCanonicalOutboundMessage)
|
||||||
|
.filter((message): message is WebDashboardRoomMessage => Boolean(message));
|
||||||
|
const canonicalDeliveryMessageIds = new Set(
|
||||||
|
(args.outboundItems ?? [])
|
||||||
|
.map((item) => item.delivery_message_id)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
);
|
||||||
|
const recentMessages = args.messages
|
||||||
|
.filter((message) => !canonicalDeliveryMessageIds.has(message.id))
|
||||||
|
.filter((message) => !isTaskStatusMessage(message))
|
||||||
|
.map(sanitizeRoomMessage)
|
||||||
|
.filter((message): message is WebDashboardRoomMessage => Boolean(message))
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
!isDuplicateOfCanonicalOutbound(message, canonicalOutboundMessages),
|
||||||
|
);
|
||||||
|
const shouldExposeExecutionOutputs = args.outboundItems === undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: args.serviceId,
|
serviceId: args.serviceId,
|
||||||
@@ -587,12 +699,9 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
elapsedMs: args.entry.elapsedMs,
|
elapsedMs: args.entry.elapsedMs,
|
||||||
pendingMessages: args.entry.pendingMessages,
|
pendingMessages: args.entry.pendingMessages,
|
||||||
pendingTasks: args.entry.pendingTasks,
|
pendingTasks: args.entry.pendingTasks,
|
||||||
messages: args.messages
|
messages: [...recentMessages, ...canonicalOutboundMessages].sort(
|
||||||
.filter((message) => !isTaskStatusMessage(message))
|
compareRoomMessagesByTimestamp,
|
||||||
.map(sanitizeRoomMessage)
|
),
|
||||||
.filter((message): message is WebDashboardRoomMessage =>
|
|
||||||
Boolean(message),
|
|
||||||
),
|
|
||||||
pairedTask: args.pairedTask
|
pairedTask: args.pairedTask
|
||||||
? {
|
? {
|
||||||
id: args.pairedTask.id,
|
id: args.pairedTask.id,
|
||||||
@@ -603,12 +712,14 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
currentTurn: currentTurn
|
currentTurn: currentTurn
|
||||||
? sanitizeRoomTurn(currentTurn, currentAttempt)
|
? sanitizeRoomTurn(currentTurn, currentAttempt)
|
||||||
: null,
|
: null,
|
||||||
outputs: args.outputs
|
outputs: shouldExposeExecutionOutputs
|
||||||
.slice(-outputLimit)
|
? args.outputs
|
||||||
.map(sanitizeRoomTurnOutput)
|
.slice(-outputLimit)
|
||||||
.filter((output): output is WebDashboardRoomTurnOutput =>
|
.map(sanitizeRoomTurnOutput)
|
||||||
Boolean(output),
|
.filter((output): output is WebDashboardRoomTurnOutput =>
|
||||||
),
|
Boolean(output),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { gunzipSync } from 'node:zlib';
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type {
|
||||||
|
PairedTurnAttemptRecord,
|
||||||
|
PairedTurnRecord,
|
||||||
|
WorkItem,
|
||||||
|
} from './db.js';
|
||||||
import type { StatusSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot } from './status-dashboard.js';
|
||||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||||
import {
|
import {
|
||||||
@@ -83,11 +87,35 @@ function roomDeps(
|
|||||||
loadPairedTurnAttempts: () => [],
|
loadPairedTurnAttempts: () => [],
|
||||||
loadPairedTurnOutputs: () => [],
|
loadPairedTurnOutputs: () => [],
|
||||||
loadRecentPairedTurnOutputsForChat: () => [],
|
loadRecentPairedTurnOutputsForChat: () => [],
|
||||||
|
loadRecentDeliveredWorkItemsForChat: () => [],
|
||||||
loadRecentChatMessages: () => [],
|
loadRecentChatMessages: () => [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workItem(overrides: Partial<WorkItem> = {}): WorkItem {
|
||||||
|
return {
|
||||||
|
id: 9001,
|
||||||
|
group_folder: 'ops-room',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
agent_type: 'codex',
|
||||||
|
service_id: 'codex-main',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
status: 'delivered',
|
||||||
|
start_seq: null,
|
||||||
|
end_seq: null,
|
||||||
|
result_payload: 'owner final output',
|
||||||
|
attachments: [],
|
||||||
|
delivery_attempts: 1,
|
||||||
|
delivery_message_id: 'discord-owner-final',
|
||||||
|
last_error: null,
|
||||||
|
created_at: '2026-04-26T05:18:00.000Z',
|
||||||
|
updated_at: '2026-04-26T05:18:30.000Z',
|
||||||
|
delivered_at: '2026-04-26T05:18:30.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('web dashboard room routes', () => {
|
describe('web dashboard room routes', () => {
|
||||||
it('serves room timeline with paired progress and recent messages', async () => {
|
it('serves room timeline with paired progress and recent messages', async () => {
|
||||||
const pairedTask = makePairedTask();
|
const pairedTask = makePairedTask();
|
||||||
@@ -168,6 +196,7 @@ describe('web dashboard room routes', () => {
|
|||||||
loadPairedTurnsForTask: () => turns,
|
loadPairedTurnsForTask: () => turns,
|
||||||
loadPairedTurnAttempts: () => attempts,
|
loadPairedTurnAttempts: () => attempts,
|
||||||
loadPairedTurnOutputs: () => outputs,
|
loadPairedTurnOutputs: () => outputs,
|
||||||
|
loadRecentDeliveredWorkItemsForChat: () => [workItem()],
|
||||||
loadRecentChatMessages: (_jid, limit) => {
|
loadRecentChatMessages: (_jid, limit) => {
|
||||||
requestedMessageLimits.push(limit ?? 20);
|
requestedMessageLimits.push(limit ?? 20);
|
||||||
return messages;
|
return messages;
|
||||||
@@ -187,10 +216,18 @@ describe('web dashboard room routes', () => {
|
|||||||
lastError: 'OPENAI_API_KEY=<redacted>',
|
lastError: 'OPENAI_API_KEY=<redacted>',
|
||||||
progressText: 'checking current output',
|
progressText: 'checking current output',
|
||||||
});
|
});
|
||||||
expect(body.pairedTask.outputs).toMatchObject([
|
expect(body.pairedTask.outputs).toEqual([]);
|
||||||
{ outputText: 'owner final output' },
|
|
||||||
]);
|
|
||||||
expect(requestedMessageLimits).toEqual([8]);
|
expect(requestedMessageLimits).toEqual([8]);
|
||||||
|
expect(body.messages).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
|
||||||
|
senderName: '눈쟁이',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'owner final output',
|
||||||
|
senderName: 'owner',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
expect(body.messages[0]).toMatchObject({
|
expect(body.messages[0]).toMatchObject({
|
||||||
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
|
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
|
||||||
senderName: '눈쟁이',
|
senderName: '눈쟁이',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { gzipSync } from 'node:zlib';
|
import { gzipSync } from 'node:zlib';
|
||||||
|
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type {
|
||||||
|
PairedTurnAttemptRecord,
|
||||||
|
PairedTurnRecord,
|
||||||
|
WorkItem,
|
||||||
|
} from './db.js';
|
||||||
import type { StatusSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot } from './status-dashboard.js';
|
||||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +34,10 @@ export interface RoomsTimelineRouteDependencies {
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
) => PairedTurnOutput[];
|
) => PairedTurnOutput[];
|
||||||
|
loadRecentDeliveredWorkItemsForChat: (
|
||||||
|
chatJid: string,
|
||||||
|
limit: number,
|
||||||
|
) => WorkItem[];
|
||||||
loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[];
|
loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +105,7 @@ function buildRoomActivity(
|
|||||||
turns,
|
turns,
|
||||||
attempts,
|
attempts,
|
||||||
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
|
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
|
||||||
|
outboundItems: deps.loadRecentDeliveredWorkItemsForChat(entry.jid, 12),
|
||||||
messages: deps.loadRecentChatMessages(entry.jid, 8),
|
messages: deps.loadRecentChatMessages(entry.jid, 8),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,7 +138,15 @@ export function buildRoomsTimelineResult(
|
|||||||
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
|
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
|
||||||
const messages = deps.loadRecentChatMessages(jid, 8);
|
const messages = deps.loadRecentChatMessages(jid, 8);
|
||||||
const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8);
|
const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8);
|
||||||
if (!pairedTask && messages.length === 0 && outputs.length === 0) continue;
|
const outboundItems = deps.loadRecentDeliveredWorkItemsForChat(jid, 8);
|
||||||
|
if (
|
||||||
|
!pairedTask &&
|
||||||
|
messages.length === 0 &&
|
||||||
|
outputs.length === 0 &&
|
||||||
|
outboundItems.length === 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const latestTurn = pairedTask
|
const latestTurn = pairedTask
|
||||||
? deps.loadLatestPairedTurnForTask(pairedTask.id)
|
? deps.loadLatestPairedTurnForTask(pairedTask.id)
|
||||||
@@ -142,6 +159,7 @@ export function buildRoomsTimelineResult(
|
|||||||
turns,
|
turns,
|
||||||
attempts: [],
|
attempts: [],
|
||||||
outputs,
|
outputs,
|
||||||
|
outboundItems,
|
||||||
messages,
|
messages,
|
||||||
outputLimit: 8,
|
outputLimit: 8,
|
||||||
});
|
});
|
||||||
@@ -161,9 +179,6 @@ export function ensureRoomsTimelineCache(
|
|||||||
deps: RoomsTimelineRouteDependencies,
|
deps: RoomsTimelineRouteDependencies,
|
||||||
): RoomsTimelineCache {
|
): RoomsTimelineCache {
|
||||||
const key = computeRoomsCacheKey(deps);
|
const key = computeRoomsCacheKey(deps);
|
||||||
if (roomsTimelineCache && roomsTimelineCache.key === key) {
|
|
||||||
return roomsTimelineCache;
|
|
||||||
}
|
|
||||||
const result = buildRoomsTimelineResult(deps);
|
const result = buildRoomsTimelineResult(deps);
|
||||||
const rawJson = JSON.stringify(result);
|
const rawJson = JSON.stringify(result);
|
||||||
const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson));
|
const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson));
|
||||||
|
|||||||
@@ -313,13 +313,13 @@ describe('web dashboard server handler', () => {
|
|||||||
getPairedTurnAttempts: () => [],
|
getPairedTurnAttempts: () => [],
|
||||||
getPairedTurnOutputs: () => [],
|
getPairedTurnOutputs: () => [],
|
||||||
getRecentPairedTurnOutputsForChat: () => [],
|
getRecentPairedTurnOutputsForChat: () => [],
|
||||||
|
getRecentDeliveredWorkItemsForChat: () => [],
|
||||||
getRecentChatMessages: (_jid, limit) => {
|
getRecentChatMessages: (_jid, limit) => {
|
||||||
requestedMessageLimits.push(limit ?? 20);
|
requestedMessageLimits.push(limit ?? 20);
|
||||||
return messages;
|
return messages;
|
||||||
},
|
},
|
||||||
startBackgroundCacheRefresh: false,
|
startBackgroundCacheRefresh: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await handler(
|
const response = await handler(
|
||||||
new Request(
|
new Request(
|
||||||
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`,
|
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getRecentPairedTurnOutputsForChat,
|
getRecentPairedTurnOutputsForChat,
|
||||||
getPairedTurnsForTask,
|
getPairedTurnsForTask,
|
||||||
getLatestPairedTurnForTask,
|
getLatestPairedTurnForTask,
|
||||||
|
getRecentDeliveredWorkItemsForChat,
|
||||||
getRecentChatMessages,
|
getRecentChatMessages,
|
||||||
getRecentChatMessagesBatch,
|
getRecentChatMessagesBatch,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
@@ -111,6 +112,7 @@ export interface WebDashboardHandlerOptions {
|
|||||||
getPairedTurnAttempts?: typeof getPairedTurnAttempts;
|
getPairedTurnAttempts?: typeof getPairedTurnAttempts;
|
||||||
getPairedTurnOutputs?: typeof getPairedTurnOutputs;
|
getPairedTurnOutputs?: typeof getPairedTurnOutputs;
|
||||||
getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
|
getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
|
||||||
|
getRecentDeliveredWorkItemsForChat?: typeof getRecentDeliveredWorkItemsForChat;
|
||||||
getRecentChatMessages?: typeof getRecentChatMessages;
|
getRecentChatMessages?: typeof getRecentChatMessages;
|
||||||
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
||||||
updatePairedTaskIfUnchanged?: (
|
updatePairedTaskIfUnchanged?: (
|
||||||
@@ -310,6 +312,9 @@ export function createWebDashboardHandler(
|
|||||||
opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
|
opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
|
||||||
const loadRecentPairedTurnOutputsForChat =
|
const loadRecentPairedTurnOutputsForChat =
|
||||||
opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
|
opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
|
||||||
|
const loadRecentDeliveredWorkItemsForChat =
|
||||||
|
opts.getRecentDeliveredWorkItemsForChat ??
|
||||||
|
getRecentDeliveredWorkItemsForChat;
|
||||||
const loadPairedTurnAttempts =
|
const loadPairedTurnAttempts =
|
||||||
opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
|
opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
|
||||||
const loadRecentChatMessages =
|
const loadRecentChatMessages =
|
||||||
@@ -335,6 +340,7 @@ export function createWebDashboardHandler(
|
|||||||
loadPairedTurnAttempts,
|
loadPairedTurnAttempts,
|
||||||
loadPairedTurnOutputs,
|
loadPairedTurnOutputs,
|
||||||
loadRecentPairedTurnOutputsForChat,
|
loadRecentPairedTurnOutputsForChat,
|
||||||
|
loadRecentDeliveredWorkItemsForChat,
|
||||||
loadRecentChatMessages,
|
loadRecentChatMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user