Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
/**
|
|
* groupChatMessageItems.ts
|
|
* ADR-092: Unified Turn Grouping for ChatConversationView (Telegram-style chat)
|
|
*
|
|
* Groups a flat ChatMessageItem[] array into ChatMessageItemTurn[] for rendering:
|
|
* - Consecutive user messages from same sender → single human turn
|
|
* - Agent thinking + tool_call + tool_result + final text → single agent turn
|
|
* - Date separators preserved as separate items
|
|
*/
|
|
|
|
import type { ChatMessageItem } from '../components/ChatConversationView';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Turn — the unit rendered by ChatConversationView
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ChatMessageItemTurn {
|
|
/** Stable key for React */
|
|
id: string;
|
|
/** 'human' for user messages, 'agent' for assistant/tool/thinking groups */
|
|
turnType: 'human' | 'agent';
|
|
/** Whether this is the current user's message */
|
|
isOwn: boolean;
|
|
/** Sender info (from first message in group) */
|
|
sender?: ChatMessageItem['sender'];
|
|
/** Grouped messages — 1 for simple, N for agent steps */
|
|
messages: ChatMessageItem[];
|
|
/** Whether this is the first message in a consecutive group from same sender */
|
|
isFirstInGroup: boolean;
|
|
/** Whether this is the last message in a consecutive group from same sender */
|
|
isLastInGroup: boolean;
|
|
/** Whether agent is still processing */
|
|
isProcessing: boolean;
|
|
}
|
|
|
|
/**
|
|
* Groups flat ChatMessageItem[] into ChatMessageItemTurn[] suitable for
|
|
* the Telegram-style ChatConversationView rendering.
|
|
*
|
|
* Agent messages with contentType (thinking, tool_call, tool_result)
|
|
* are grouped together into a single agent turn with the final text.
|
|
* Consecutive user messages from the same sender are grouped into a
|
|
* single human turn.
|
|
*/
|
|
export function groupChatMessageItems(
|
|
messages: ChatMessageItem[],
|
|
opts: {
|
|
chatType: 'agent' | 'direct' | 'group' | 'task';
|
|
currentUserId?: number;
|
|
isAgentProcessing?: boolean;
|
|
},
|
|
): ChatMessageItemTurn[] {
|
|
const { chatType, currentUserId, isAgentProcessing = false } = opts;
|
|
const turns: ChatMessageItemTurn[] = [];
|
|
|
|
let currentAgentMessages: ChatMessageItem[] = [];
|
|
let currentHumanMessages: ChatMessageItem[] = [];
|
|
let currentHumanSenderId: number | string | undefined;
|
|
/** Buffer for consecutive standalone agent text messages (no tool steps) */
|
|
let currentAgentTextMessages: ChatMessageItem[] = [];
|
|
let turnCounter = 0;
|
|
|
|
const isOwnMessage = (msg: ChatMessageItem): boolean => {
|
|
if (chatType === 'agent') {
|
|
return msg.role === 'user';
|
|
}
|
|
return !!(currentUserId && msg.sender?.id === currentUserId);
|
|
};
|
|
|
|
/** Flush accumulated agent steps into a turn */
|
|
const flushAgentSteps = (processing = false) => {
|
|
if (currentAgentMessages.length === 0) return;
|
|
|
|
const firstMsg = currentAgentMessages[0];
|
|
turns.push({
|
|
id: `aturn_${firstMsg.id || turnCounter}`,
|
|
turnType: 'agent',
|
|
isOwn: false,
|
|
sender: firstMsg.sender,
|
|
messages: [...currentAgentMessages],
|
|
isFirstInGroup: true,
|
|
isLastInGroup: true,
|
|
isProcessing: processing,
|
|
});
|
|
|
|
currentAgentMessages = [];
|
|
turnCounter++;
|
|
};
|
|
|
|
/** Flush accumulated consecutive standalone agent text messages into a single turn */
|
|
const flushAgentTextMessages = () => {
|
|
if (currentAgentTextMessages.length === 0) return;
|
|
|
|
const firstMsg = currentAgentTextMessages[0];
|
|
turns.push({
|
|
id: `aturn_${firstMsg.id || turnCounter}`,
|
|
turnType: 'agent',
|
|
isOwn: false,
|
|
sender: firstMsg.sender,
|
|
messages: [...currentAgentTextMessages],
|
|
isFirstInGroup: true,
|
|
isLastInGroup: true,
|
|
isProcessing: false,
|
|
});
|
|
|
|
currentAgentTextMessages = [];
|
|
turnCounter++;
|
|
};
|
|
|
|
/** Flush accumulated consecutive human messages into a single turn */
|
|
const flushHumanMessages = () => {
|
|
if (currentHumanMessages.length === 0) return;
|
|
|
|
const firstMsg = currentHumanMessages[0];
|
|
turns.push({
|
|
id: `hturn_${firstMsg.id || turnCounter}`,
|
|
turnType: 'human',
|
|
isOwn: isOwnMessage(firstMsg),
|
|
sender: firstMsg.sender,
|
|
messages: [...currentHumanMessages],
|
|
isFirstInGroup: true,
|
|
isLastInGroup: true,
|
|
isProcessing: false,
|
|
});
|
|
|
|
currentHumanMessages = [];
|
|
currentHumanSenderId = undefined;
|
|
turnCounter++;
|
|
};
|
|
|
|
for (const msg of messages) {
|
|
const ct = msg.contentType;
|
|
|
|
// ── User messages: group consecutive from same sender ──
|
|
if (msg.role === 'user') {
|
|
flushAgentSteps();
|
|
flushAgentTextMessages();
|
|
|
|
const senderId = msg.sender?.id ?? '__me__';
|
|
const isSameSender =
|
|
currentHumanMessages.length > 0 &&
|
|
(senderId === currentHumanSenderId ||
|
|
senderId === '__me__' ||
|
|
currentHumanSenderId === '__me__');
|
|
|
|
if (isSameSender) {
|
|
currentHumanMessages.push(msg);
|
|
if (currentHumanSenderId === '__me__' && senderId !== '__me__') {
|
|
currentHumanSenderId = senderId;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Different sender or first message — flush previous
|
|
flushHumanMessages();
|
|
currentHumanSenderId = senderId;
|
|
currentHumanMessages.push(msg);
|
|
continue;
|
|
}
|
|
|
|
// ── Any non-user message flushes human batch ──
|
|
flushHumanMessages();
|
|
|
|
// Agent step types: thinking, tool_call, tool_result
|
|
if (ct === 'thinking' || ct === 'tool_call' || ct === 'tool_result') {
|
|
flushAgentTextMessages(); // steps start a new agent turn
|
|
currentAgentMessages.push(msg);
|
|
continue;
|
|
}
|
|
|
|
// 'text' from assistant after tool steps → attach to agent steps as final response
|
|
if (msg.role === 'assistant' && ct === 'text' && currentAgentMessages.length > 0) {
|
|
currentAgentMessages.push(msg);
|
|
flushAgentSteps();
|
|
continue;
|
|
}
|
|
|
|
// Regular message (no contentType, or plain assistant text without prior steps)
|
|
flushAgentSteps();
|
|
|
|
// Check if it's an agent-type message
|
|
const isAgent = msg.role === 'assistant' || msg.role === 'tool' || msg.role === 'system';
|
|
|
|
if (isAgent) {
|
|
// Standalone agent text — buffer for merging consecutive agent messages
|
|
currentAgentTextMessages.push(msg);
|
|
} else {
|
|
// Standalone human message
|
|
flushAgentTextMessages();
|
|
turns.push({
|
|
id: `hturn_${msg.id || turnCounter}`,
|
|
turnType: 'human',
|
|
isOwn: isOwnMessage(msg),
|
|
sender: msg.sender,
|
|
messages: [msg],
|
|
isFirstInGroup: true,
|
|
isLastInGroup: true,
|
|
isProcessing: false,
|
|
});
|
|
turnCounter++;
|
|
}
|
|
}
|
|
|
|
// Flush remaining
|
|
flushHumanMessages();
|
|
flushAgentTextMessages();
|
|
flushAgentSteps(isAgentProcessing);
|
|
|
|
return turns;
|
|
}
|