Files
kandev/apps/web/hooks/use-processed-messages.ts
T
2026-06-02 21:48:23 +01:00

501 lines
17 KiB
TypeScript

import { useEffect, useMemo } from "react";
import {
sessionId as toSessionId,
taskId as toTaskId,
type ClarificationRequestMetadata,
type Message,
type MessageType,
} from "@/lib/types/http";
import type { RichMetadata, ToolCallMetadata, TodoSnapshot } from "@/components/task/chat/types";
import {
findPendingClarification,
findPendingClarificationGroup,
} from "@/lib/utils/pending-clarification";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
const debug = createDebugLogger("messages:process");
function countByType(messages: Message[]): Record<string, number> {
const out: Record<string, number> = {};
for (const m of messages) {
const t = m.type ?? "unknown";
out[t] = (out[t] ?? 0) + 1;
}
return out;
}
function useDebugProcessedPipeline(args: {
sessionId: string | null;
messages: Message[];
visibleMessages: Message[];
footerActionCount: number;
groupedItems: RenderItem[];
}) {
const { sessionId, messages, visibleMessages, footerActionCount, groupedItems } = args;
useEffect(() => {
if (!isDebug()) return;
debug("pipeline", {
sessionId,
input: { count: messages.length, byType: countByType(messages) },
afterFilter: { count: visibleMessages.length, byType: countByType(visibleMessages) },
droppedByFilter: messages.length - visibleMessages.length,
footerActionCount,
groupedItemKinds: groupedItems.reduce<Record<string, number>>((acc, item) => {
acc[item.type] = (acc[item.type] ?? 0) + 1;
return acc;
}, {}),
turnGroupSizes: groupedItems
.filter((i): i is TurnGroup => i.type === "turn_group")
.map((g) => ({ turnId: g.turnId, size: g.messages.length })),
});
}, [sessionId, messages, visibleMessages, footerActionCount, groupedItems]);
}
const ACTIVITY_MESSAGE_TYPES: Set<MessageType> = new Set([
"thinking",
"tool_call",
"tool_edit",
"tool_read",
"tool_execute",
"tool_search",
]);
const VISIBLE_MESSAGE_TYPES: Set<string> = new Set([
"message",
"content",
"tool_call",
"tool_read",
"tool_edit",
"tool_execute",
"tool_search",
"progress",
"status",
"error",
"thinking",
"todo",
"script_execution",
"agent_plan",
]);
function isVisibleMessageType(type: MessageType | undefined): boolean {
return !type || VISIBLE_MESSAGE_TYPES.has(type);
}
function isPermissionVisible(message: Message, toolCallIds: Set<string>): boolean {
const metadata = message.metadata as { tool_call_id?: string; status?: string } | undefined;
const toolCallId = metadata?.tool_call_id;
if (toolCallId && toolCallIds.has(toolCallId)) return false;
const status = metadata?.status;
if (status === "approved" || status === "denied" || status === "cancelled") return false;
return true;
}
export type TurnGroup = {
type: "turn_group";
id: string;
turnId: string | null;
messages: Message[];
};
export type PrepareProgressItem = {
type: "prepare_progress";
id: string;
sessionId: string;
};
export type RenderItem = { type: "message"; message: Message } | TurnGroup | PrepareProgressItem;
export type GroupedRenderOptions = {
canAnchorPrepareProgress?: boolean;
};
export type ProcessedMessagesOptions = {
hasOlderMessages?: boolean;
};
function buildToolCallIds(messages: Message[]): Set<string> {
const set = new Set<string>();
for (const message of messages) {
if (message.type === "tool_call") {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) set.add(toolCallId);
}
}
return set;
}
function buildPermissionsByToolCallId(messages: Message[]): Map<string, Message> {
const map = new Map<string, Message>();
for (const message of messages) {
if (message.type === "permission_request") {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) map.set(toolCallId, message);
}
}
return map;
}
function buildChildrenByParentToolCallId(messages: Message[]): Map<string, Message[]> {
const map = new Map<string, Message[]>();
for (const message of messages) {
const metadata = message.metadata as ToolCallMetadata | undefined;
const parentId = metadata?.parent_tool_call_id;
if (parentId) {
const children = map.get(parentId) || [];
children.push(message);
map.set(parentId, children);
}
}
return map;
}
function buildSubagentChildIds(childrenByParentToolCallId: Map<string, Message[]>): Set<string> {
const set = new Set<string>();
for (const children of childrenByParentToolCallId.values()) {
for (const child of children) set.add(child.id);
}
return set;
}
function isRecoveryMessage(message: Message): boolean {
const meta = message.metadata as Record<string, unknown> | undefined;
return meta?.recovery_actions === true;
}
/** Hide recovery messages that have been superseded by later conversation activity
* (user/agent messages prove the session recovered) or by a newer recovery message. */
function deduplicateRecoveryMessages(messages: Message[]): Message[] {
let lastRecoveryIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (isRecoveryMessage(messages[i])) {
lastRecoveryIdx = i;
break;
}
}
if (lastRecoveryIdx === -1) return messages;
const hasLaterActivity = messages
.slice(lastRecoveryIdx + 1)
.some((m) => m.type === "message" || m.type === "content");
return messages.filter((msg, i) => {
if (!isRecoveryMessage(msg)) return true;
if (hasLaterActivity) return false;
return i === lastRecoveryIdx;
});
}
export function isAgentBootResumeMessage(message: Message): boolean {
if (message.type !== "script_execution") return false;
const meta = message.metadata as { script_type?: string; is_resuming?: boolean } | undefined;
return meta?.script_type === "agent_boot" && meta?.is_resuming === true;
}
/** Per-repo setup scripts (`Repository.setup_script`) run during worktree
* creation and are persisted as `script_execution` rows. They belong
* conceptually to environment preparation, not the chat thread — `PrepareProgress`
* surfaces them as steps inside the env-prep panel. Hiding them from the chat
* prevents the script from rendering above the env-prep panel (which happens
* when the user prompt hasn't been recorded yet, e.g. MCP-auto-started subtasks). */
export function isSetupScriptMessage(message: Message): boolean {
if (message.type !== "script_execution") return false;
const meta = message.metadata as { script_type?: string } | undefined;
return meta?.script_type === "setup";
}
/** A resumed session may produce many "Resumed agent …" boot messages over its
* lifetime (every backend restart emits one). They all convey the same info;
* keep only the most recent and drop the rest — unconditionally, even if user
* messages occurred between them (unlike `deduplicateRecoveryMessages`). The
* underlying DB rows are untouched; this only affects the rendered chat. */
export function deduplicateAgentBootResumes(messages: Message[]): Message[] {
let lastResumeIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (isAgentBootResumeMessage(messages[i])) {
lastResumeIdx = i;
break;
}
}
if (lastResumeIdx === -1) return messages;
return messages.filter((msg, i) => !isAgentBootResumeMessage(msg) || i === lastResumeIdx);
}
function filterVisibleMessages(
messages: Message[],
toolCallIds: Set<string>,
subagentChildIds: Set<string>,
): Message[] {
const filtered = messages.filter((message) => {
if (subagentChildIds.has(message.id)) return false;
if (isSetupScriptMessage(message)) return false;
if (message.type === "clarification_request") {
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
return !(!metadata?.status || metadata.status === "pending");
}
if (
message.type === "status" &&
(message.content === "New session started" || message.content === "Session resumed")
)
return false;
if (isVisibleMessageType(message.type)) return true;
if (message.type === "permission_request") return isPermissionVisible(message, toolCallIds);
return false;
});
return collapseTodoSnapshotsPerTurn(
deduplicateAgentBootResumes(deduplicateRecoveryMessages(filtered)),
);
}
function findLatestTodoIdsByTurn(messages: Message[]): Map<string, string> {
const latest = new Map<string, string>();
for (const message of messages) {
if (message.type === "todo" && message.turn_id) {
latest.set(message.turn_id, message.id);
}
}
return latest;
}
function collectPriorSnapshotsByLatestId(
messages: Message[],
latestTodoIdByTurn: Map<string, string>,
): Map<string, TodoSnapshot[]> {
const previousByLatestId = new Map<string, TodoSnapshot[]>();
for (const message of messages) {
if (message.type !== "todo" || !message.turn_id) continue;
const latestId = latestTodoIdByTurn.get(message.turn_id);
if (!latestId || latestId === message.id) continue;
const snapshot: TodoSnapshot = {
todos: (message.metadata as RichMetadata | undefined)?.todos ?? [],
created_at: message.created_at,
};
if (!previousByLatestId.has(latestId)) previousByLatestId.set(latestId, []);
previousByLatestId.get(latestId)!.push(snapshot);
}
return previousByLatestId;
}
/** Some agents (Claude Opus/Sonnet) emit a fresh `todo` message every time
* they update their plan, producing a long stack of "Updated Todos" rows in
* the chat. Each todo message is a full snapshot of the list, so all but the
* latest in a turn are strictly stale. Collapse them: keep only the latest
* per `turn_id` and attach the earlier snapshots to its metadata as
* `previous_todo_snapshots` so the UI can show the progression on expand. */
export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] {
const latestTodoIdByTurn = findLatestTodoIdsByTurn(messages);
if (latestTodoIdByTurn.size === 0) return messages;
const previousByLatestId = collectPriorSnapshotsByLatestId(messages, latestTodoIdByTurn);
return messages.flatMap((message) => {
if (message.type !== "todo" || !message.turn_id) return [message];
if (latestTodoIdByTurn.get(message.turn_id) !== message.id) return [];
const previous = previousByLatestId.get(message.id);
if (!previous || previous.length === 0) return [message];
return [{ ...message, metadata: { ...message.metadata, previous_todo_snapshots: previous } }];
});
}
function groupActivityMessages(allMessages: Message[]): RenderItem[] {
const items: RenderItem[] = [];
let currentGroup: Message[] = [];
let currentTurnId: string | null = null;
const flushGroup = () => {
if (currentGroup.length >= 2) {
items.push({
type: "turn_group",
id: `turn-group-${currentGroup[0].id}`,
turnId: currentGroup[0].turn_id ?? null,
messages: currentGroup,
});
} else if (currentGroup.length === 1) {
items.push({ type: "message", message: currentGroup[0] });
}
currentGroup = [];
currentTurnId = null;
};
for (const message of allMessages) {
const isActivity = message.type && ACTIVITY_MESSAGE_TYPES.has(message.type);
const messageTurnId = message.turn_id ?? null;
if (isActivity && messageTurnId) {
if (currentGroup.length > 0 && currentTurnId === messageTurnId) {
currentGroup.push(message);
} else {
flushGroup();
currentGroup = [message];
currentTurnId = messageTurnId;
}
} else {
flushGroup();
items.push({ type: "message", message });
}
}
flushGroup();
return items;
}
function injectPrepareProgressItem(
items: RenderItem[],
resolvedSessionId: string | null,
options: GroupedRenderOptions = {},
): RenderItem[] {
if (!resolvedSessionId) return items;
if (options.canAnchorPrepareProgress === false) return items;
const prepareItem: PrepareProgressItem = {
type: "prepare_progress",
id: `prepare-progress-${resolvedSessionId}`,
sessionId: resolvedSessionId,
};
if (items.length === 0) return [prepareItem];
return [items[0], prepareItem, ...items.slice(1)];
}
export function buildGroupedRenderItems(
regularMessages: Message[],
resolvedSessionId: string | null,
options: GroupedRenderOptions = {},
): RenderItem[] {
return injectPrepareProgressItem(
groupActivityMessages(regularMessages),
resolvedSessionId,
options,
);
}
function canAnchorPrepareProgress(messages: Message[], hasOlderMessages: boolean): boolean {
if (!hasOlderMessages) return true;
return messages.some(isSetupScriptMessage);
}
function buildGroupedItemsForHook(args: {
regularMessages: Message[];
allSessionMessages: Message[];
resolvedSessionId: string | null;
hasOlderMessages: boolean;
}): RenderItem[] {
return buildGroupedRenderItems(args.regularMessages, args.resolvedSessionId, {
canAnchorPrepareProgress: canAnchorPrepareProgress(
args.allSessionMessages,
args.hasOlderMessages,
),
});
}
function buildTodoItems(visibleMessages: Message[]) {
const latestTodos = [...visibleMessages]
.reverse()
.find((message) => message.type === "todo" || (message.metadata as { todos?: unknown })?.todos);
return (
(
latestTodos?.metadata as
| { todos?: Array<{ text: string; done?: boolean } | string> }
| undefined
)?.todos
?.map((item) => (typeof item === "string" ? { text: item, done: false } : item))
.filter((item) => item.text) ?? []
);
}
export function useProcessedMessages(
messages: Message[],
taskId: string | null,
resolvedSessionId: string | null,
taskDescription: string | null,
options: ProcessedMessagesOptions = {},
) {
const toolCallIds = useMemo(() => buildToolCallIds(messages), [messages]);
const permissionsByToolCallId = useMemo(() => buildPermissionsByToolCallId(messages), [messages]);
const childrenByParentToolCallId = useMemo(
() => buildChildrenByParentToolCallId(messages),
[messages],
);
const subagentChildIds = useMemo(
() => buildSubagentChildIds(childrenByParentToolCallId),
[childrenByParentToolCallId],
);
const pendingClarification = useMemo(() => findPendingClarification(messages), [messages]);
const pendingClarificationGroup = useMemo(
() => findPendingClarificationGroup(messages),
[messages],
);
const visibleMessages = useMemo(
() => filterVisibleMessages(messages, toolCallIds, subagentChildIds),
[messages, toolCallIds, subagentChildIds],
);
const taskDescriptionMessage: Message | null = useMemo(() => {
return taskDescription && visibleMessages.length === 0
? {
id: "task-description",
task_id: toTaskId(taskId ?? ""),
session_id: toSessionId(resolvedSessionId ?? ""),
author_type: "user",
content: taskDescription,
type: "message",
created_at: "",
}
: null;
}, [taskDescription, visibleMessages.length, taskId, resolvedSessionId]);
const allMessages = useMemo(() => {
return taskDescriptionMessage ? [taskDescriptionMessage, ...visibleMessages] : visibleMessages;
}, [taskDescriptionMessage, visibleMessages]);
const todoItems = useMemo(() => buildTodoItems(visibleMessages), [visibleMessages]);
const agentMessageCount = useMemo(() => {
return visibleMessages.filter((c) => c.author_type !== "user").length;
}, [visibleMessages]);
// Separate footer action messages (e.g. missing branch guidance with archive/delete
// buttons) from regular messages so they render after the env prep error status.
const { regularMessages, footerActionMessages } = useMemo(() => {
const regular: Message[] = [];
const footer: Message[] = [];
for (const msg of allMessages) {
const meta = msg.metadata as Record<string, unknown> | undefined;
if (Array.isArray(meta?.actions) && !meta?.recovery_actions) {
footer.push(msg);
} else {
regular.push(msg);
}
}
return { regularMessages: regular, footerActionMessages: footer };
}, [allMessages]);
const groupedItems = useMemo<RenderItem[]>(() => {
return buildGroupedItemsForHook({
regularMessages,
allSessionMessages: messages,
resolvedSessionId,
hasOlderMessages: options.hasOlderMessages ?? false,
});
}, [regularMessages, resolvedSessionId, messages, options.hasOlderMessages]);
useDebugProcessedPipeline({
sessionId: resolvedSessionId,
messages,
visibleMessages,
footerActionCount: footerActionMessages.length,
groupedItems,
});
return {
visibleMessages,
allMessages,
groupedItems,
footerActionMessages,
toolCallIds,
permissionsByToolCallId,
childrenByParentToolCallId,
todoItems,
agentMessageCount,
pendingClarification,
pendingClarificationGroup,
};
}