diff --git a/desktop/src/features/agents/agentConversationRecap.ts b/desktop/src/features/agents/agentConversationRecap.ts new file mode 100644 index 000000000..33c33c15d --- /dev/null +++ b/desktop/src/features/agents/agentConversationRecap.ts @@ -0,0 +1,202 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { AgentConversationRecapInput } from "./agentConversations"; +import { + normalizeTitleToken, + sentenceCaseTitle, +} from "./agentConversationTitles"; + +function normalizeRecapComparisonText(text: string | null | undefined): string { + return (text ?? "").replace(/\s+/g, " ").trim().toLocaleLowerCase(); +} + +function isGenericRecapText(text: string): boolean { + const normalized = normalizeRecapComparisonText(text); + + return ( + normalized.length < 3 || + normalized === "thinking" || + normalized === "thinking..." || + /^what can i help you with\b/.test(normalized) || + /^of course\b.*\bwhat do you need help with\??$/.test(normalized) || + (/^(sure|okay|ok|got it|i get it|i understand)\b/.test(normalized) && + /\b(?:summarize|summary|recap)\b/.test(normalized) && + /\b(?:you want|you'd like|you're asking|you asked)\b/.test(normalized)) + ); +} + +function formatRecapMessageText(message: TimelineMessage): string | null { + const body = message.body ?? ""; + if ( + /^\s*(?:\*\*)?Outcome from continued conversation/i.test(body) || + /^\s*Please send a concise summary of this continued conversation/i.test( + body, + ) || + /^\s*Please create a concise conversation recap/i.test(body) || + /^\s*thinking\.{0,3}\s*$/i.test(body) + ) { + return null; + } + + const cleaned = body + .replace(/\r\n/g, "\n") + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]+\)/g, "media") + .replace(/https?:\/\/\S+/g, "link") + .replace(/@\S+/g, "") + .replace(/^[\s,.:;-]*(ok|okay|so|also|then|and then|um|uh)[\s,.:;-]+/i, "") + .replace(/^(i think|i guess|i wonder if|maybe|basically)[\s,.:;-]+/i, "") + .replace(/^(can|could|would) (you|we)\s+/i, "") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() + .replace(/[.!?]+$/, ""); + + if (!cleaned || isGenericRecapText(cleaned)) { + return null; + } + + return sentenceCaseTitle(cleaned); +} + +function isSameRecapPoint( + left: string | null | undefined, + right: string | null | undefined, +) { + return ( + normalizeRecapComparisonText(left) === normalizeRecapComparisonText(right) + ); +} + +function appendUniqueRecapPoint(points: string[], point: string | null) { + if (!point) { + return; + } + + if (points.some((current) => isSameRecapPoint(current, point))) { + return; + } + + points.push(point); +} + +function normalizeInlineOrderedListBreaks(value: string): string { + const itemMatches = [...value.matchAll(/(?:^|\s)(\d+)\.\s+/g)]; + if (itemMatches.length < 2) { + return value; + } + + return value.replace(/\s+(?=\d+\.\s+)/g, "\n"); +} + +function formatRecapSection( + label: string, + value: string | null, +): string | null { + if (!value) { + return null; + } + + const formattedValue = normalizeInlineOrderedListBreaks(value); + const firstListIndex = formattedValue.search(/(?:^|\n)\d+\.\s/); + if (firstListIndex < 0) { + return `**${label}:** ${formattedValue}`; + } + + const preface = formattedValue.slice(0, firstListIndex).trim(); + const list = formattedValue.slice(firstListIndex).trim(); + + return preface + ? `**${label}:** ${preface}\n\n${list}` + : `**${label}:**\n\n${list}`; +} + +function singleLineRecapText(value: string | null): string | null { + if (!value) { + return null; + } + + return value.replace(/\s+/g, " ").trim(); +} + +export function buildAgentConversationRecap({ + agentPubkeys, + messages, +}: AgentConversationRecapInput): string | null { + const normalizedAgentPubkeys = new Set( + [...agentPubkeys].map((pubkey) => normalizeTitleToken(pubkey)), + ); + const usableMessages = [...messages] + .flatMap((message, originalIndex) => { + const text = formatRecapMessageText(message); + if (!text) { + return []; + } + + return [ + { + isAgent: + message.pubkey != null && + normalizedAgentPubkeys.has(normalizeTitleToken(message.pubkey)), + message, + originalIndex, + text, + }, + ]; + }) + .sort( + (left, right) => + left.message.createdAt - right.message.createdAt || + left.originalIndex - right.originalIndex, + ); + + if (usableMessages.length === 0) { + return null; + } + + const humanMessages = usableMessages.filter((entry) => !entry.isAgent); + const agentMessages = usableMessages.filter((entry) => entry.isAgent); + const firstHumanText = humanMessages[0]?.text ?? null; + const latestHumanText = humanMessages[humanMessages.length - 1]?.text ?? null; + const originalRequest = + firstHumanText && + latestHumanText && + !isSameRecapPoint(firstHumanText, latestHumanText) + ? `${singleLineRecapText(firstHumanText)} Later clarified: ${singleLineRecapText(latestHumanText)}` + : firstHumanText; + + const outcomeMessage = [...agentMessages].reverse()[0] ?? null; + const latestAgentByPubkey = new Map(); + for (const entry of agentMessages) { + if (entry.message.id === outcomeMessage?.message.id) { + continue; + } + + latestAgentByPubkey.set( + normalizeTitleToken(entry.message.pubkey ?? entry.message.author), + entry, + ); + } + const findingPoints: string[] = []; + for (const entry of [...latestAgentByPubkey.values()].slice(-3)) { + const prefix = + latestAgentByPubkey.size > 1 ? `${entry.message.author}: ` : ""; + appendUniqueRecapPoint(findingPoints, `${prefix}${entry.text}`); + } + const findings = findingPoints.join(" ") || null; + const outcome = outcomeMessage?.text ?? null; + + const latestMessage = usableMessages[usableMessages.length - 1]; + const nextSteps = + !latestMessage.isAgent && !isSameRecapPoint(latestHumanText, firstHumanText) + ? `Follow up on the latest question: ${latestMessage.text}` + : null; + const sections = [ + formatRecapSection("Original request", originalRequest), + formatRecapSection("Findings", findings), + formatRecapSection("Outcome", outcome), + formatRecapSection("Next steps", nextSteps), + ].filter((section): section is string => section !== null); + + return sections.length > 0 ? sections.join("\n\n") : null; +} diff --git a/desktop/src/features/agents/agentConversationTitles.ts b/desktop/src/features/agents/agentConversationTitles.ts new file mode 100644 index 000000000..49dd8addb --- /dev/null +++ b/desktop/src/features/agents/agentConversationTitles.ts @@ -0,0 +1,582 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { + AgentConversation, + AgentConversationTitleStatus, + OpenAgentConversationInput, +} from "./agentConversations"; + +const MIN_CONTEXT_MESSAGES_FOR_TOPIC_TITLE = 3; +const MIN_MEANINGFUL_HUMAN_MESSAGES_FOR_TOPIC_TITLE = 2; +const CONCISE_TITLE_MAX_WORDS = 5; +const CONCISE_TITLE_MAX_CHARS = 44; +const GENERIC_REFERENCE_WORDS = new Set([ + "actually", + "again", + "also", + "bit", + "even", + "half", + "just", + "kind", + "little", + "maybe", + "more", + "much", + "really", + "same", + "slightly", + "sort", + "still", + "thing", + "things", +]); +const TITLE_STOP_WORDS = new Set([ + "a", + "about", + "an", + "and", + "app", + "are", + "as", + "be", + "but", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "get", + "had", + "has", + "have", + "having", + "help", + "how", + "i", + "if", + "in", + "into", + "is", + "it", + "its", + "kind", + "kinds", + "like", + "me", + "mean", + "meant", + "of", + "on", + "or", + "our", + "please", + "product", + "that", + "the", + "their", + "them", + "there", + "tell", + "this", + "to", + "type", + "types", + "us", + "was", + "we", + "what", + "when", + "where", + "which", + "with", + "work", + "working", + "would", + "you", + "your", +]); +const TOPIC_TOKEN_PRIORITY = new Map([ + ["animation", 18], + ["composer", 18], + ["conversation", 18], + ["conversations", 18], + ["data", 50], + ["header", 18], + ["link", 18], + ["message", 14], + ["messages", 14], + ["padding", 18], + ["search", 18], + ["sidebar", 18], + ["spacing", 18], + ["thread", 18], + ["threads", 18], + ["title", 22], + ["titles", 22], + ["user", 16], + ["users", 16], +]); +const TOPIC_ANCHOR_SUFFIX = + "app|product|workspace|relay|channel|thread|conversation|sidebar|composer|header|inbox|panel|title|link|button|row|animation|shimmer|screen|view"; +const TOPIC_ANCHOR_PATTERN = new RegExp( + `\\b(?:the\\s+)?([A-Z][A-Za-z0-9_-]*(?:\\s+[A-Z][A-Za-z0-9_-]+){0,2}\\s+(?:${TOPIC_ANCHOR_SUFFIX}))\\b`, + "g", +); + +function compactMessageText(message: TimelineMessage | null): string | null { + if ( + /^\s*(?:\*\*)?Outcome from continued conversation/i.test( + message?.body ?? "", + ) || + /^\s*Please send a concise summary of this continued conversation/i.test( + message?.body ?? "", + ) || + /^\s*Please create a concise conversation recap/i.test( + message?.body ?? "", + ) || + /^\s*thinking\.{0,3}\s*$/i.test(message?.body ?? "") + ) { + return null; + } + + const compact = message?.body + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]+\)/g, "media") + .replace(/https?:\/\/\S+/g, "link") + .replace(/@\S+/g, "") + .replace(/^[\s,.:;-]*(ok|okay|so|also|then|and then|um|uh)[\s,.:;-]+/i, "") + .replace(/^(i think|i guess|i wonder if|maybe|basically)[\s,.:;-]+/i, "") + .replace(/^(can|could|would) (you|we)\s+/i, "") + .replace(/\s+/g, " ") + .trim() + .replace(/[.!?]+$/, ""); + + if (!compact) { + return null; + } + + return compact; +} + +function normalizeWorkTitleText(text: string): string { + let normalized = text; + + for (let index = 0; index < 4; index += 1) { + const next = normalized + .replace(/^(?:i\s+)?(?:mean|meant),?\s+/i, "") + .replace(/^(?:i\s+)?(?:think|guess|wonder)(?:\s+that)?(?:\s+if)?\s+/i, "") + .replace(/^(?:can|could|would|should)\s+(?:you|we)\s+/i, "") + .replace(/^(?:i\s+)?(?:just\s+)?(?:want|wanted)\s+(?:to\s+)?/i, "") + .replace(/^(?:what\s+)?(?:i\s+)?(?:would\s+)?like\s+(?:is\s+)?/i, "") + .replace(/^(?:also|okay|ok|so|then|and then|actually)\s+/i, "") + .replace(/^(?:just|maybe)\s+/i, "") + .trim(); + + if (next === normalized) { + break; + } + normalized = next; + } + + return normalized + .replace(/\b(?:like|basically|kind of|sort of)\b[,\s]*/gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isGenericConversationTitle(text: string): boolean { + const normalized = text.toLowerCase(); + + return ( + /^(respond|reply|answer|can respond|can reply)$/.test(normalized) || + /^what can i help you with\b/.test(normalized) || + /^of course\b/.test(normalized) || + /^(thanks|thank you|got it|sounds good)$/.test(normalized) + ); +} + +function formatConversationTitle(text: string): string { + const sentenceEnd = text.search(/[.!?]\s/); + const candidate = sentenceEnd > 12 ? text.slice(0, sentenceEnd).trim() : text; + const words = candidate.split(" "); + const title = + words.length > CONCISE_TITLE_MAX_WORDS + ? words.slice(0, CONCISE_TITLE_MAX_WORDS).join(" ") + : candidate; + + return title.length > CONCISE_TITLE_MAX_CHARS + ? `${title.slice(0, CONCISE_TITLE_MAX_CHARS - 3).trimEnd()}...` + : title; +} + +export function sentenceCaseTitle(text: string): string { + if (!text) { + return text; + } + + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}`; +} + +function titleCaseToken(token: string): string { + if (token.toUpperCase() === token && token.length <= 4) { + return token; + } + + return `${token.charAt(0).toLocaleUpperCase()}${token.slice(1).toLocaleLowerCase()}`; +} + +export function normalizeTitleToken(token: string): string { + const normalized = token + .toLocaleLowerCase() + .replace(/'s$/, "") + .replace(/[^a-z0-9_-]/g, ""); + + if (normalized.endsWith("ies") && normalized.length > 4) { + return `${normalized.slice(0, -3)}y`; + } + if (normalized.endsWith("s") && normalized.length > 4) { + return normalized.slice(0, -1); + } + + return normalized; +} + +function extractConciseTopicPhrase(text: string): string | null { + const normalized = normalizeWorkTitleText(text) + .replace( + /^(?:tell me about|talk about|explain|describe|summarize|look into|look at|check|review|investigate)\s+/i, + "", + ) + .replace( + /^what\s+(?:kind|types?)\s+of\s+(.+?)(?:\s+(?:do|does|did|is|are|we|you)\b|$).*/i, + "$1", + ) + .replace( + /^what\s+(.+?)\s+(?:do|does|did|can|could|would|should|is|are)\b.*$/i, + "$1", + ) + .replace( + /\b(?:do\s+)?(?:we|you|i)\s+(?:have|store|collect|track|use|show|need|want)\b/gi, + "", + ) + .replace( + /\b(?:about|around|for|of)\s+(?:how|what|why|when|where|whether)\b.*$/i, + "", + ) + .replace(/\b(?:so that|because|when|if|whether)\b.*$/i, "") + .replace(/\s+/g, " ") + .trim() + .replace(/[.!?]+$/, ""); + + if (!normalized) { + return null; + } + + return formatConversationTitle(normalized); +} + +function titleFromMessage( + message: TimelineMessage | null, + options?: { allowGeneric?: boolean; workTitle?: boolean }, +): string | null { + const compact = compactMessageText(message); + if (!compact) { + return null; + } + + const title = sentenceCaseTitle( + formatConversationTitle( + options?.workTitle + ? (extractConciseTopicPhrase(compact) ?? + normalizeWorkTitleText(compact)) + : compact, + ), + ); + if (!title) { + return null; + } + + if (!options?.allowGeneric && isGenericConversationTitle(title)) { + return null; + } + + return title; +} + +function countSpecificTitleTokens(title: string): number { + return title + .toLowerCase() + .split(/[^a-z0-9_-]+/) + .filter((token) => { + if (token.length <= 2) { + return false; + } + if (TITLE_STOP_WORDS.has(token)) { + return false; + } + if (GENERIC_REFERENCE_WORDS.has(token)) { + return false; + } + + return true; + }).length; +} + +function isReferentialTitle(title: string): boolean { + const normalized = title.toLowerCase(); + if (!/\b(?:it|that|this|those|these|them|one)\b/.test(normalized)) { + return false; + } + + return countSpecificTitleTokens(title) < 3; +} + +function extractTopicAnchors(text: string): string[] { + TOPIC_ANCHOR_PATTERN.lastIndex = 0; + + return [...text.matchAll(TOPIC_ANCHOR_PATTERN)] + .map((match) => match[1]?.trim()) + .filter((anchor): anchor is string => Boolean(anchor)); +} + +function pickTopicAnchor(texts: readonly string[]): string | null { + const anchors = new Map(); + + texts.forEach((text, index) => { + for (const anchor of extractTopicAnchors(text)) { + const key = anchor.toLocaleLowerCase(); + const current = anchors.get(key); + const score = 24 + index * 5 + anchor.split(/\s+/).length * 2; + anchors.set(key, { + display: current?.display ?? anchor, + score: (current?.score ?? 0) + score, + }); + } + }); + + return ( + [...anchors.values()].sort((left, right) => right.score - left.score)[0] + ?.display ?? null + ); +} + +function pickPrimaryTopicTerm(texts: readonly string[]): { + display: string; + normalized: string; +} | null { + const terms = new Map< + string, + { display: string; firstSeen: number; score: number } + >(); + + texts.forEach((text, index) => { + const phrase = + extractConciseTopicPhrase(text) ?? normalizeWorkTitleText(text); + for (const match of phrase.matchAll(/[A-Za-z][A-Za-z0-9_-]*/g)) { + const rawToken = match[0]; + const normalized = normalizeTitleToken(rawToken); + if ( + !normalized || + TITLE_STOP_WORDS.has(normalized) || + GENERIC_REFERENCE_WORDS.has(normalized) + ) { + continue; + } + + const priority = + TOPIC_TOKEN_PRIORITY.get(normalized) ?? + TOPIC_TOKEN_PRIORITY.get(rawToken.toLocaleLowerCase()) ?? + 0; + const current = terms.get(normalized); + terms.set(normalized, { + display: current?.display ?? titleCaseToken(rawToken), + firstSeen: current?.firstSeen ?? index, + score: (current?.score ?? 0) + 8 + index * 3 + priority, + }); + } + }); + + const best = [...terms.entries()].sort( + (left, right) => + right[1].score - left[1].score || left[1].firstSeen - right[1].firstSeen, + )[0]; + + if (!best || best[1].score < 10) { + return null; + } + + return { display: best[1].display, normalized: best[0] }; +} + +function deriveConciseContextTitle({ + contextMessages, + normalizedAgentPubkey, +}: { + contextMessages: TimelineMessage[]; + normalizedAgentPubkey: string; +}): string | null { + const humanTexts = contextMessages + .filter( + (message) => + message.pubkey?.toLocaleLowerCase() !== normalizedAgentPubkey, + ) + .map((message) => compactMessageText(message)) + .filter((text): text is string => Boolean(text)); + + if (humanTexts.length === 0) { + return null; + } + + const anchor = pickTopicAnchor(humanTexts); + const primaryTerm = pickPrimaryTopicTerm(humanTexts); + if (anchor && primaryTerm) { + const normalizedAnchor = anchor.toLocaleLowerCase(); + if (!normalizedAnchor.includes(primaryTerm.normalized)) { + return `${primaryTerm.display} in ${anchor}`; + } + + return sentenceCaseTitle(anchor); + } + + const latestSpecificPhrase = [...humanTexts] + .reverse() + .map((text) => extractConciseTopicPhrase(text)) + .find( + (title): title is string => + title != null && + !isReferentialTitle(title) && + countSpecificTitleTokens(title) > 0, + ); + + return latestSpecificPhrase ? sentenceCaseTitle(latestSpecificPhrase) : null; +} + +export function collectConversationContextMessages( + input: OpenAgentConversationInput, + threadRootId: string, +): TimelineMessage[] { + const byId = new Map(); + const add = (message: TimelineMessage | null | undefined) => { + if (message) { + byId.set(message.id, message); + } + }; + + add(input.threadRootMessage); + add(input.parentMessage); + add(input.agentReply); + + for (const message of input.contextMessages ?? []) { + if ( + message.id === threadRootId || + message.id === input.agentReply.id || + message.rootId === threadRootId || + message.parentId === threadRootId + ) { + add(message); + } + } + + return [...byId.values()].sort( + (left, right) => left.createdAt - right.createdAt, + ); +} + +export function deriveTitleFromContext({ + agentPubkey, + agentReply, + contextMessages, + parentMessage, + threadRootId, + threadRootMessage, +}: { + agentPubkey: string; + agentReply: TimelineMessage; + contextMessages: TimelineMessage[]; + parentMessage: TimelineMessage | null; + threadRootId: string; + threadRootMessage: TimelineMessage | null; +}): { status: AgentConversationTitleStatus; title: string } { + const normalizedAgentPubkey = agentPubkey.toLowerCase(); + const titleCandidates = contextMessages.flatMap((message, index) => { + const isAgentMessage = + message.pubkey?.toLowerCase() === normalizedAgentPubkey; + const title = titleFromMessage(message, { workTitle: !isAgentMessage }); + if (!title) { + return []; + } + + let score = Math.min(title.length, 80) + index * 10; + if (!isAgentMessage) score += 120; + if (message.id === threadRootId) score -= 20; + if (message.id === parentMessage?.id) score += 10; + if (message.id === agentReply.id) score += isAgentMessage ? -30 : 10; + score += countSpecificTitleTokens(title) * 12; + if (isReferentialTitle(title)) score -= 80; + + return [ + { + isAgentMessage, + isReferential: isReferentialTitle(title), + score, + title, + }, + ]; + }); + const humanTitleCandidates = titleCandidates.filter( + (candidate) => !candidate.isAgentMessage, + ); + const meaningfulHumanCount = humanTitleCandidates.length; + const hasEnoughContext = + contextMessages.length >= MIN_CONTEXT_MESSAGES_FOR_TOPIC_TITLE || + meaningfulHumanCount >= MIN_MEANINGFUL_HUMAN_MESSAGES_FOR_TOPIC_TITLE; + + if (!hasEnoughContext) { + return { status: "provisional", title: "New conversation" }; + } + + const conciseContextTitle = deriveConciseContextTitle({ + contextMessages, + normalizedAgentPubkey, + }); + if (conciseContextTitle) { + return { status: "resolved", title: conciseContextTitle }; + } + + const latestSpecificHumanTitle = [...humanTitleCandidates] + .reverse() + .find((candidate) => !candidate.isReferential)?.title; + const latestHumanTitle = + latestSpecificHumanTitle ?? [...humanTitleCandidates].reverse()[0]?.title; + const bestTitle = + latestHumanTitle ?? + titleCandidates.sort((left, right) => right.score - left.score)[0]?.title; + + return { + status: bestTitle ? "resolved" : "provisional", + title: + bestTitle ?? + titleFromMessage(threadRootMessage, { allowGeneric: true }) ?? + titleFromMessage(parentMessage, { allowGeneric: true }) ?? + titleFromMessage(agentReply, { allowGeneric: true }) ?? + "New conversation", + }; +} + +export function deriveAgentConversationTitle( + conversation: Pick< + AgentConversation, + | "agentPubkey" + | "agentReply" + | "contextMessages" + | "parentMessage" + | "threadRootId" + | "threadRootMessage" + >, +): { status: AgentConversationTitleStatus; title: string } { + return deriveTitleFromContext(conversation); +} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs new file mode 100644 index 000000000..4f0eff1d7 --- /dev/null +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -0,0 +1,738 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAgentConversationMentionPubkeys, + buildAgentConversation, + buildAgentConversationRecap, + buildAgentConversationMarkers, + deriveAgentConversationTitle, + getAutoRoutedAgentConversationPubkeys, + getHiddenAgentConversationMessageIds, + parseAgentConversationMarker, + readPersistedAgentConversations, + writePersistedAgentConversations, +} from "./agentConversations.ts"; +import { + buildAgentConversationTypingScopeIds, + isConversationMessage, +} from "./ui/AgentConversationScreen.helpers.ts"; + +function message({ body, createdAt, id, pubkey = "human" }) { + return { + author: pubkey === "agent" ? "Fizz" : "Kenny Lopez", + body, + createdAt, + depth: id === "root" ? 0 : 1, + id, + parentId: id === "root" ? null : "root", + pubkey, + rootId: id === "root" ? null : "root", + time: "1:00 PM", + }; +} + +test("continued conversation title condenses a refined Buzz data thread", () => { + const root = message({ + body: "Can you tell me about what kind of data we have in the Buzz app?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "Sure, the app has channel, message, and membership data.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const refinement = message({ + body: "I meant, what data do we have about how the users use the product?", + createdAt: 3, + id: "refinement", + }); + + const title = deriveAgentConversationTitle({ + agentPubkey: "agent", + agentReply, + contextMessages: [root, agentReply, refinement], + parentMessage: root, + threadRootId: root.id, + threadRootMessage: root, + }); + + assert.deepEqual(title, { + status: "resolved", + title: "Data in Buzz app", + }); +}); + +test("continued conversation typing scope includes selected task messages", () => { + const root = message({ + body: "Can you check the buttons?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "I'll look.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const taskFollowUp = message({ + body: "Can you include composer buttons?", + createdAt: 3, + id: "task-follow-up", + }); + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply, + channel: { id: "channel", name: "design" }, + contextMessages: [root, agentReply, taskFollowUp], + parentMessage: root, + threadRootMessage: root, + }); + + const scopeIds = buildAgentConversationTypingScopeIds(conversation, [ + root, + agentReply, + taskFollowUp, + ]); + + assert.equal(scopeIds.has("root"), true); + assert.equal(scopeIds.has("agent-reply"), true); + assert.equal(scopeIds.has("task-follow-up"), true); + assert.equal(scopeIds.has("other-thread-message"), false); +}); + +test("continued conversation auto-routes only a single messageable agent", () => { + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: true, pubkey: "agent-one" }, + ]), + ["agent-one"], + ); + + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: true, pubkey: "agent-one" }, + { canMessage: false, pubkey: "agent-two" }, + { canMessage: false, pubkey: "agent-three" }, + ]), + ["agent-one"], + ); + + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: true, pubkey: "agent-one" }, + { canMessage: true, pubkey: "agent-two" }, + { canMessage: false, pubkey: "agent-three" }, + ]), + [], + ); + + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: false, pubkey: "agent-one" }, + ]), + [], + ); +}); + +test("continued conversation mention routing preserves explicit multi-agent mentions", () => { + assert.deepEqual( + buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: [], + mentionPubkeys: ["agent-one"], + }), + ["agent-one"], + ); + + assert.deepEqual( + buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); + +function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) { + return { + id, + pubkey: "starter", + created_at: createdAt, + kind: 40004, + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "agent-reply", "", "agent-reply"], + ["p", "agent"], + ["title", "Data in Buzz app"], + ], + content: JSON.stringify({ + version: 1, + title: "Data in Buzz app", + titleStatus: "resolved", + agentName: "Fizz", + agentPubkey: "agent", + threadRootId: "root", + threadRootMessageId: "root", + parentMessageId: "root", + agentReplyId: "agent-reply", + ...content, + }), + sig: "sig", + }; +} + +function withMockLocalStorage(callback) { + const originalWindow = globalThis.window; + const store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + }, + }; + + try { + callback(); + } finally { + if (originalWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + } +} + +test("continued conversation marker parses summary metadata", () => { + const marker = parseAgentConversationMarker( + markerEvent({ + content: { + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + }), + ); + + assert.equal( + marker?.summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(marker?.summaryAuthorName, "Fizz"); + assert.equal(marker?.summaryAuthorPubkey, "agent"); + assert.equal(marker?.summaryCreatedAt, 12); +}); + +test("continued conversations persist across app restarts", () => { + withMockLocalStorage(() => { + const workspaceScope = "wss://relay.example.com"; + const root = message({ + body: "Can you look at the Buzz data model?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "I can look at it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply, + channel: { id: "channel", name: "general" }, + contextMessages: [root, agentReply], + parentMessage: root, + threadRootMessage: root, + }); + + writePersistedAgentConversations("human", workspaceScope, [conversation]); + const persisted = readPersistedAgentConversations("human", workspaceScope); + const otherWorkspace = readPersistedAgentConversations( + "human", + "wss://other.example.com", + ); + + assert.equal(persisted.length, 1); + assert.equal(persisted[0].id, conversation.id); + assert.equal(persisted[0].channelId, "channel"); + assert.equal(persisted[0].agentReply.id, "agent-reply"); + assert.equal(otherWorkspace.length, 0); + }); +}); + +test("continued conversation marker summary update replaces earlier marker", () => { + const markers = buildAgentConversationMarkers([ + markerEvent({ + content: { + startedAt: 10, + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + createdAt: 2, + id: "second", + }), + markerEvent({ content: { startedAt: 1 }, createdAt: 1, id: "first" }), + ]); + + assert.equal(markers.length, 1); + assert.equal(markers[0].eventId, "second"); + assert.equal( + markers[0].summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(markers[0].startedAt, 1); +}); + +test("continued conversation marker keeps recap across title-only updates", () => { + const markers = buildAgentConversationMarkers([ + markerEvent({ + content: { + startedAt: 1, + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + createdAt: 2, + id: "summary", + }), + markerEvent({ + content: { + startedAt: 1, + title: "Updated Buzz data topic", + }, + createdAt: 3, + id: "title-only", + }), + ]); + + assert.equal(markers.length, 1); + assert.equal(markers[0].eventId, "title-only"); + assert.equal(markers[0].title, "Updated Buzz data topic"); + assert.equal( + markers[0].summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(markers[0].summaryAuthorName, "Fizz"); +}); + +test("continued conversation recap summarizes full conversation context", () => { + const root = message({ + body: "Can you tell me about what kind of data we have in the Buzz app?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "Sure, Buzz stores channel, message, and membership data.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const refinement = message({ + body: "What data do we have about how users use the product?", + createdAt: 3, + id: "refinement", + }); + const finalAnswer = message({ + body: "For usage, Buzz tracks:\n1. Channel participation\n2. Message activity\n3. Thread engagement signals.", + createdAt: 4, + id: "final-answer", + pubkey: "agent", + }); + + const recap = buildAgentConversationRecap({ + agentPubkeys: new Set(["agent"]), + conversationTitle: "Data in Buzz app", + messages: [root, agentReply, refinement, finalAnswer], + }); + + assert.match(recap ?? "", /\*\*Original request:\*\*/); + assert.match(recap ?? "", /Later clarified:/); + assert.match(recap ?? "", /\*\*Findings:\*\*/); + assert.match(recap ?? "", /\*\*Outcome:\*\*/); + assert.match(recap ?? "", /usage/i); + assert.match( + recap ?? "", + /\*\*Outcome:\*\* For usage, Buzz tracks:\n\n1\. Channel participation\n2\. Message activity\n3\. Thread engagement signals/, + ); + assert.doesNotMatch(recap ?? "", /1\. Channel participation 2\./); + assert.doesNotMatch(recap ?? "", /^- Topic:/m); + assert.doesNotMatch(recap ?? "", /Agent response:/); + assert.doesNotMatch(recap ?? "", /Current state:/); +}); + +test("continued conversation recap keeps long outcome text", () => { + const root = message({ + body: "Can you summarize the button patterns in Buzz?", + createdAt: 1, + id: "root", + }); + const longOutcome = `${"Buzz has several button variants and sizing patterns. ".repeat(30)}Final implementation note: keep the full recap visible without truncation.`; + const agentReply = message({ + body: longOutcome, + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + + const recap = buildAgentConversationRecap({ + agentPubkeys: new Set(["agent"]), + messages: [root, agentReply], + }); + + assert.match( + recap ?? "", + /Final implementation note: keep the full recap visible without truncation/, + ); + assert.doesNotMatch(recap ?? "", /\.\.\.$/); +}); + +test("continued conversation marker hides source-thread messages after its anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const beforeMarker = message({ + body: "One note before opening.", + createdAt: 3, + id: "before", + }); + const afterMarker = message({ + body: "This belongs in the dedicated conversation.", + createdAt: 5, + id: "after", + pubkey: "agent", + }); + + const marker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 4 }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, agentReply, beforeMarker, afterMarker], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], ["before", "after"]); +}); + +test("continued conversation marker hides same-second messages after the anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const beforeMarker = message({ + body: "One note before opening.", + createdAt: 4, + id: "before", + }); + const agentReply = message({ + body: "I'll look into it.", + createdAt: 4, + id: "agent-reply", + pubkey: "agent", + }); + const afterMarker = message({ + body: "Still working through this.", + createdAt: 4, + id: "after", + pubkey: "agent", + }); + + const marker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 4 }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, beforeMarker, agentReply, afterMarker], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], ["after"]); +}); + +test("continued conversation marker with a missing anchor does not hide thread messages", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const reply = message({ + body: "One note before opening.", + createdAt: 3, + id: "reply", + }); + const marker = parseAgentConversationMarker( + markerEvent({ content: { agentReplyId: "missing-reply" }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, reply], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], []); +}); + +test("continued conversation marker hides loaded task replies when anchor is outside the window", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const taskReply = message({ + body: "This newer reply belongs in the dedicated conversation.", + createdAt: 5, + id: "task-reply", + pubkey: "agent", + }); + const marker = parseAgentConversationMarker( + markerEvent({ + content: { agentReplyId: "missing-reply", startedAt: 4 }, + createdAt: 4, + }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, taskReply], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], ["task-reply"]); +}); + +test("continued conversation markers keep later task anchors visible", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const hiddenReply = message({ + body: "This should live in the first task.", + createdAt: 3, + id: "hidden", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const laterReply = message({ + body: "This should also be hidden.", + createdAt: 5, + id: "later", + }); + + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, firstAnchor, hiddenReply, secondAnchor, laterReply], + [firstMarker, secondMarker].filter(Boolean), + ); + + assert.deepEqual([...hiddenIds], ["hidden", "later"]); +}); + +test("dedicated conversation view stops at the next task anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const firstTaskReply = message({ + body: "This belongs in the first task.", + createdAt: 3, + id: "first-task-reply", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const secondTaskReply = message({ + body: "This belongs in the second task.", + createdAt: 5, + id: "second-task-reply", + }); + const messages = [ + root, + firstAnchor, + firstTaskReply, + secondAnchor, + secondTaskReply, + ]; + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply: firstAnchor, + channel: { id: "channel", name: "general" }, + contextMessages: messages, + parentMessage: root, + threadRootMessage: root, + }); + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + const markers = [firstMarker, secondMarker].filter(Boolean); + + const visibleIds = messages + .filter((entry) => + isConversationMessage(entry, conversation, markers, messages), + ) + .map((entry) => entry.id); + + assert.deepEqual(visibleIds, ["root", "agent-reply", "first-task-reply"]); +}); + +test("dedicated conversation view keeps older task descendant replies", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const firstTaskReply = message({ + body: "This belongs in the first task.", + createdAt: 3, + id: "first-task-reply", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const secondTaskReply = message({ + body: "This belongs in the second task.", + createdAt: 5, + id: "second-task-reply", + }); + const firstTaskFollowup = { + ...message({ + body: "Continuing the first task after task two started.", + createdAt: 6, + id: "first-task-followup", + }), + parentId: "first-task-reply", + rootId: "root", + }; + const plainRootReply = message({ + body: "A plain root reply after task two should not join task one.", + createdAt: 7, + id: "plain-root-reply", + }); + const messages = [ + root, + firstAnchor, + firstTaskReply, + secondAnchor, + secondTaskReply, + firstTaskFollowup, + plainRootReply, + ]; + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply: firstAnchor, + channel: { id: "channel", name: "general" }, + contextMessages: messages, + parentMessage: root, + threadRootMessage: root, + }); + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + const markers = [firstMarker, secondMarker].filter(Boolean); + + const visibleIds = messages + .filter((entry) => + isConversationMessage(entry, conversation, markers, messages), + ) + .map((entry) => entry.id); + + assert.deepEqual(visibleIds, [ + "root", + "agent-reply", + "first-task-reply", + "first-task-followup", + ]); +}); diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts new file mode 100644 index 000000000..02eb23d86 --- /dev/null +++ b/desktop/src/features/agents/agentConversations.ts @@ -0,0 +1,739 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { + KIND_AGENT_CONVERSATION, + KIND_AGENT_CONVERSATION_COMPAT, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + collectConversationContextMessages, + deriveTitleFromContext, +} from "./agentConversationTitles"; + +export { buildAgentConversationRecap } from "./agentConversationRecap"; +export { deriveAgentConversationTitle } from "./agentConversationTitles"; + +const HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX = + "buzz-hidden-agent-conversations.v1"; +const AGENT_CONVERSATIONS_STORAGE_PREFIX = "buzz-agent-conversations.v1"; +const MAX_PERSISTED_AGENT_CONVERSATIONS = 100; +export type AgentConversationTitleStatus = "provisional" | "resolved"; + +export type AgentConversation = { + id: string; + agentName: string; + agentPubkey: string; + agentReply: TimelineMessage; + channelId: string; + channelName: string; + contextMessages: TimelineMessage[]; + createdAt: number; + parentMessage: TimelineMessage | null; + threadRootId: string; + threadRootMessage: TimelineMessage | null; + title: string; + titleStatus: AgentConversationTitleStatus; +}; + +export type OpenAgentConversationInput = { + agentName: string; + agentPubkey: string; + agentReply: TimelineMessage; + channel: Pick; + contextMessages?: TimelineMessage[]; + parentMessage: TimelineMessage | null; + threadRootMessage: TimelineMessage | null; +}; + +export type AgentConversationMarker = { + agentName: string; + agentPubkey: string; + agentReplyId: string; + channelId: string; + createdAt: number; + eventId: string; + parentMessageId: string | null; + startedAt: number; + starterPubkey: string; + summary: string | null; + summaryAuthorName: string | null; + summaryAuthorPubkey: string | null; + summaryCreatedAt: number | null; + threadRootMessageId: string | null; + threadRootId: string; + title: string; + titleStatus: AgentConversationTitleStatus; +}; + +export type AgentConversationMarkerUpdate = { + startedAt?: number | null; + summary?: string | null; + summaryAuthorName?: string | null; + summaryAuthorPubkey?: string | null; + summaryCreatedAt?: number | null; +}; + +export type AgentConversationRecapInput = { + agentPubkeys: ReadonlySet | readonly string[]; + conversationTitle?: string | null; + messages: readonly TimelineMessage[]; +}; + +export type AgentConversationRouteableParticipant = { + canMessage: boolean; + pubkey: string; +}; + +function normalizeAgentConversationStorageScope( + workspaceScope: string | null | undefined, +): string { + const normalizedScope = workspaceScope?.trim().replace(/\/+$/, ""); + return normalizedScope || "unknown-workspace"; +} + +export function hiddenAgentConversationsStorageKey( + pubkey: string, + workspaceScope: string | null | undefined, +): string { + return `${HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX}:${normalizeAgentConversationStorageScope(workspaceScope)}:${pubkey}`; +} + +export function agentConversationsStorageKey( + pubkey: string, + workspaceScope: string | null | undefined, +): string { + return `${AGENT_CONVERSATIONS_STORAGE_PREFIX}:${normalizeAgentConversationStorageScope(workspaceScope)}:${pubkey}`; +} + +export function getAutoRoutedAgentConversationPubkeys( + participants: readonly AgentConversationRouteableParticipant[], +): string[] { + const messageableParticipants = participants.filter( + (participant) => participant.canMessage, + ); + + if (messageableParticipants.length !== 1) { + return []; + } + + return [messageableParticipants[0].pubkey]; +} + +export function buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys, + mentionPubkeys, +}: { + autoRouteAgentPubkeys: readonly string[]; + mentionPubkeys: readonly string[]; +}): string[] { + const seenPubkeys = new Set(); + const merged: string[] = []; + const add = (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!normalized || seenPubkeys.has(normalized)) { + return; + } + + seenPubkeys.add(normalized); + merged.push(pubkey); + }; + + for (const pubkey of autoRouteAgentPubkeys) { + add(pubkey); + } + for (const pubkey of mentionPubkeys) { + add(pubkey); + } + + return merged; +} + +export function readHiddenAgentConversationIds( + pubkey: string, + workspaceScope: string | null | undefined, +): Set { + try { + const raw = window.localStorage.getItem( + hiddenAgentConversationsStorageKey(pubkey, workspaceScope), + ); + if (!raw) { + return new Set(); + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return new Set(); + } + + return new Set( + parsed.filter((value): value is string => typeof value === "string"), + ); + } catch { + return new Set(); + } +} + +export function writeHiddenAgentConversationIds( + pubkey: string, + workspaceScope: string | null | undefined, + ids: ReadonlySet, +): void { + try { + window.localStorage.setItem( + hiddenAgentConversationsStorageKey(pubkey, workspaceScope), + JSON.stringify([...ids]), + ); + } catch { + // Best-effort local preference; ignore storage failures. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function maybeString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function maybeNullableString(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + + return maybeString(value); +} + +function parseStoredTimelineMessage(value: unknown): TimelineMessage | null { + if (!isRecord(value)) { + return null; + } + + const id = maybeString(value.id); + const author = maybeString(value.author); + const body = maybeString(value.body); + const createdAt = + typeof value.createdAt === "number" && Number.isFinite(value.createdAt) + ? value.createdAt + : null; + if (!id || !author || !body || createdAt === null) { + return null; + } + + const message = { ...value } as TimelineMessage; + message.id = id; + message.author = author; + message.body = body; + message.createdAt = createdAt; + message.depth = + typeof value.depth === "number" && Number.isFinite(value.depth) + ? value.depth + : 0; + message.time = maybeString(value.time) ?? ""; + message.pubkey = maybeString(value.pubkey); + message.parentId = maybeNullableString(value.parentId); + message.rootId = maybeNullableString(value.rootId); + message.avatarUrl = maybeNullableString(value.avatarUrl); + message.renderKey = maybeString(value.renderKey); + + return message; +} + +function parseStoredAgentConversation( + value: unknown, +): AgentConversation | null { + if (!isRecord(value)) { + return null; + } + + const id = maybeString(value.id); + const agentName = maybeString(value.agentName); + const agentPubkey = maybeString(value.agentPubkey); + const channelId = maybeString(value.channelId); + const channelName = maybeString(value.channelName); + const threadRootId = maybeString(value.threadRootId); + const title = maybeString(value.title); + const titleStatus = + value.titleStatus === "provisional" || value.titleStatus === "resolved" + ? value.titleStatus + : null; + const createdAt = + typeof value.createdAt === "number" && Number.isFinite(value.createdAt) + ? value.createdAt + : null; + const agentReply = parseStoredTimelineMessage(value.agentReply); + const contextMessages = Array.isArray(value.contextMessages) + ? value.contextMessages + .map(parseStoredTimelineMessage) + .filter((message): message is TimelineMessage => message !== null) + : []; + const parentMessage = + value.parentMessage == null + ? null + : parseStoredTimelineMessage(value.parentMessage); + const threadRootMessage = + value.threadRootMessage == null + ? null + : parseStoredTimelineMessage(value.threadRootMessage); + + if ( + !id || + !agentName || + !agentPubkey || + !agentReply || + !channelId || + !channelName || + createdAt === null || + !threadRootId || + !title || + !titleStatus + ) { + return null; + } + + return { + id, + agentName, + agentPubkey, + agentReply, + channelId, + channelName, + contextMessages, + createdAt, + parentMessage, + threadRootId, + threadRootMessage, + title, + titleStatus, + }; +} + +export function readPersistedAgentConversations( + pubkey: string, + workspaceScope: string | null | undefined, +): AgentConversation[] { + try { + const raw = window.localStorage.getItem( + agentConversationsStorageKey(pubkey, workspaceScope), + ); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + const byId = new Map(); + for (const value of parsed) { + const conversation = parseStoredAgentConversation(value); + if (conversation) { + byId.set(conversation.id, conversation); + } + } + + return [...byId.values()] + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, MAX_PERSISTED_AGENT_CONVERSATIONS); + } catch { + return []; + } +} + +export function writePersistedAgentConversations( + pubkey: string, + workspaceScope: string | null | undefined, + conversations: readonly AgentConversation[], +): void { + try { + const byId = new Map(); + for (const conversation of conversations) { + byId.set(conversation.id, conversation); + } + + const persisted = [...byId.values()] + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, MAX_PERSISTED_AGENT_CONVERSATIONS); + window.localStorage.setItem( + agentConversationsStorageKey(pubkey, workspaceScope), + JSON.stringify(persisted), + ); + } catch { + // Best-effort local preference; ignore storage failures. + } +} + +export function buildAgentConversation( + input: OpenAgentConversationInput, +): AgentConversation { + const threadRootId = + input.threadRootMessage?.id ?? + input.agentReply.rootId ?? + input.agentReply.parentId ?? + input.agentReply.id; + const contextMessages = collectConversationContextMessages( + input, + threadRootId, + ); + const { status: titleStatus, title } = deriveTitleFromContext({ + agentPubkey: input.agentPubkey, + agentReply: input.agentReply, + contextMessages, + parentMessage: input.parentMessage, + threadRootId, + threadRootMessage: input.threadRootMessage, + }); + + return { + id: `${input.channel.id}:${input.agentPubkey}:${input.agentReply.id}`, + agentName: input.agentName, + agentPubkey: input.agentPubkey, + agentReply: input.agentReply, + channelId: input.channel.id, + channelName: input.channel.name, + contextMessages, + createdAt: Math.max( + input.agentReply.createdAt, + input.threadRootMessage?.createdAt ?? 0, + input.parentMessage?.createdAt ?? 0, + ...contextMessages.map((message) => message.createdAt), + ), + parentMessage: input.parentMessage, + threadRootId, + threadRootMessage: input.threadRootMessage, + title, + titleStatus, + }; +} + +function getTagValue(tags: string[][], name: string): string | null { + return tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +function getMarkedEventId(tags: string[][], marker: string): string | null { + return ( + tags.find( + (tag) => + tag[0] === "e" && + typeof tag[1] === "string" && + tag[1].length > 0 && + tag[3] === marker, + )?.[1] ?? null + ); +} + +function parseMarkerContent(content: string): Record { + try { + const parsed = JSON.parse(content); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function trimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function parseAgentConversationMarker( + event: RelayEvent, +): AgentConversationMarker | null { + if ( + event.kind !== KIND_AGENT_CONVERSATION && + event.kind !== KIND_AGENT_CONVERSATION_COMPAT + ) { + return null; + } + + const content = parseMarkerContent(event.content); + const channelId = getTagValue(event.tags, "h"); + const threadRootId = + getMarkedEventId(event.tags, "root") ?? + (typeof content.threadRootId === "string" ? content.threadRootId : null); + const agentReplyId = + getMarkedEventId(event.tags, "agent-reply") ?? + (typeof content.agentReplyId === "string" ? content.agentReplyId : null); + const agentPubkey = + getTagValue(event.tags, "p") ?? + (typeof content.agentPubkey === "string" ? content.agentPubkey : null); + const parentMessageId = + typeof content.parentMessageId === "string" + ? content.parentMessageId + : null; + const threadRootMessageId = + typeof content.threadRootMessageId === "string" + ? content.threadRootMessageId + : null; + const agentName = trimmedString(content.agentName) || agentPubkey || "Agent"; + const title = + trimmedString(content.title) ?? + getTagValue(event.tags, "title") ?? + "New conversation"; + const titleStatus = + content.titleStatus === "provisional" ? "provisional" : "resolved"; + const summary = trimmedString(content.summary); + const summaryCreatedAt = + typeof content.summaryCreatedAt === "number" && + Number.isFinite(content.summaryCreatedAt) + ? content.summaryCreatedAt + : null; + const startedAt = + typeof content.startedAt === "number" && Number.isFinite(content.startedAt) + ? content.startedAt + : event.created_at; + + if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) { + return null; + } + + return { + agentName, + agentPubkey, + agentReplyId, + channelId, + createdAt: event.created_at, + eventId: event.id, + parentMessageId, + startedAt, + starterPubkey: event.pubkey, + summary, + summaryAuthorName: trimmedString(content.summaryAuthorName), + summaryAuthorPubkey: trimmedString(content.summaryAuthorPubkey), + summaryCreatedAt, + threadRootMessageId, + threadRootId, + title, + titleStatus, + }; +} + +export function buildAgentConversationMarkers( + events: readonly RelayEvent[], +): AgentConversationMarker[] { + const byAgentReplyId = new Map(); + + for (const event of events) { + const marker = parseAgentConversationMarker(event); + if (!marker) { + continue; + } + + const current = byAgentReplyId.get(marker.agentReplyId); + if ( + !current || + marker.createdAt > current.createdAt || + (marker.createdAt === current.createdAt && + marker.eventId > current.eventId) + ) { + byAgentReplyId.set(marker.agentReplyId, { + ...marker, + startedAt: Math.min( + current?.startedAt ?? marker.startedAt, + marker.startedAt, + ), + summary: marker.summary ?? current?.summary ?? null, + summaryAuthorName: + marker.summary != null + ? marker.summaryAuthorName + : (current?.summaryAuthorName ?? null), + summaryAuthorPubkey: + marker.summary != null + ? marker.summaryAuthorPubkey + : (current?.summaryAuthorPubkey ?? null), + summaryCreatedAt: + marker.summary != null + ? marker.summaryCreatedAt + : (current?.summaryCreatedAt ?? null), + }); + } else if (marker.startedAt < current.startedAt) { + byAgentReplyId.set(marker.agentReplyId, { + ...current, + startedAt: marker.startedAt, + summary: current.summary ?? marker.summary, + summaryAuthorName: current.summary + ? current.summaryAuthorName + : marker.summaryAuthorName, + summaryAuthorPubkey: current.summary + ? current.summaryAuthorPubkey + : marker.summaryAuthorPubkey, + summaryCreatedAt: current.summary + ? current.summaryCreatedAt + : marker.summaryCreatedAt, + }); + } else if (current.summary == null && marker.summary != null) { + byAgentReplyId.set(marker.agentReplyId, { + ...current, + summary: marker.summary, + summaryAuthorName: marker.summaryAuthorName, + summaryAuthorPubkey: marker.summaryAuthorPubkey, + summaryCreatedAt: marker.summaryCreatedAt, + }); + } + } + + return [...byAgentReplyId.values()].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} + +export async function publishAgentConversationMarker( + input: OpenAgentConversationInput, + update: AgentConversationMarkerUpdate = {}, +): Promise { + const conversation = buildAgentConversation(input); + const startedAt = + typeof update.startedAt === "number" && Number.isFinite(update.startedAt) + ? update.startedAt + : Math.floor(Date.now() / 1_000); + const parentMessageId = input.parentMessage?.id ?? null; + const threadRootMessageId = input.threadRootMessage?.id ?? null; + const summary = update.summary?.trim() || null; + const summaryAuthorName = update.summaryAuthorName?.trim() || null; + const summaryAuthorPubkey = update.summaryAuthorPubkey?.trim() || null; + const content = JSON.stringify({ + version: 1, + title: conversation.title, + titleStatus: conversation.titleStatus, + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + startedAt, + threadRootId: conversation.threadRootId, + threadRootMessageId, + parentMessageId, + agentReplyId: conversation.agentReply.id, + ...(summary + ? { + summary, + summaryAuthorName, + summaryAuthorPubkey, + summaryCreatedAt: update.summaryCreatedAt ?? null, + } + : {}), + }); + const event = await signRelayEvent({ + kind: KIND_AGENT_CONVERSATION_COMPAT, + content, + tags: [ + ["h", conversation.channelId], + ["e", conversation.threadRootId, "", "root"], + ["e", conversation.agentReply.id, "", "agent-reply"], + ["p", conversation.agentPubkey], + ["title", conversation.title], + ], + }); + + return relayClient.publishEvent( + event, + "Timed out opening the agent conversation.", + "Failed to open the agent conversation.", + ); +} + +export function getHiddenAgentConversationMessageIds( + messages: readonly TimelineMessage[], + markers: readonly AgentConversationMarker[] | undefined, +): Set { + if (!markers?.length || messages.length === 0) { + return new Set(); + } + + const orderedMessages = messages + .map((message, originalIndex) => ({ message, originalIndex })) + .sort( + (left, right) => + left.message.createdAt - right.message.createdAt || + left.originalIndex - right.originalIndex, + ); + const messageIndexById = new Map( + orderedMessages.map(({ message }, index) => [message.id, index]), + ); + const messageById = new Map( + orderedMessages.map(({ message }) => [message.id, message]), + ); + const anchorMessageIdsByThreadRootId = new Map>(); + const cutoffByThreadRootId = new Map< + string, + { + anchorIndex: number | null; + startedAt: number; + } + >(); + for (const marker of markers) { + const anchorMessage = messageById.get(marker.agentReplyId); + const anchorIndex = messageIndexById.get(marker.agentReplyId); + if (!anchorMessage || anchorIndex === undefined) { + const hasLoadedThreadContext = orderedMessages.some(({ message }) => { + const messageThreadRootId = message.rootId ?? message.parentId ?? null; + return ( + message.id === marker.threadRootId || + messageThreadRootId === marker.threadRootId + ); + }); + if (!hasLoadedThreadContext) { + continue; + } + } else { + const anchorThreadRootId = + anchorMessage.rootId ?? anchorMessage.parentId ?? anchorMessage.id; + if (anchorThreadRootId !== marker.threadRootId) { + continue; + } + + const anchorMessageIds = + anchorMessageIdsByThreadRootId.get(marker.threadRootId) ?? new Set(); + anchorMessageIds.add(marker.agentReplyId); + anchorMessageIdsByThreadRootId.set(marker.threadRootId, anchorMessageIds); + } + + const current = cutoffByThreadRootId.get(marker.threadRootId); + const candidate = { + anchorIndex: anchorIndex ?? null, + startedAt: marker.startedAt, + }; + const isEarlier = + current === undefined || + candidate.startedAt < current.startedAt || + (candidate.startedAt === current.startedAt && + candidate.anchorIndex !== null && + current.anchorIndex !== null && + candidate.anchorIndex < current.anchorIndex); + if (isEarlier) { + cutoffByThreadRootId.set(marker.threadRootId, candidate); + } + } + + const hiddenIds = new Set(); + for (const { message } of orderedMessages) { + const threadRootId = message.rootId ?? message.parentId ?? null; + if (!threadRootId || message.id === threadRootId) { + continue; + } + + const cutoff = cutoffByThreadRootId.get(threadRootId); + if ( + cutoff === undefined || + anchorMessageIdsByThreadRootId.get(threadRootId)?.has(message.id) + ) { + continue; + } + + const messageIndex = messageIndexById.get(message.id); + if (messageIndex !== undefined && cutoff.anchorIndex !== null) { + if (messageIndex > cutoff.anchorIndex) { + hiddenIds.add(message.id); + } + continue; + } + + if (message.createdAt >= cutoff.startedAt) { + hiddenIds.add(message.id); + } + } + + return hiddenIds; +} diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs index 3519c8214..943df50d4 100644 --- a/desktop/src/shared/api/relayChannelFilters.test.mjs +++ b/desktop/src/shared/api/relayChannelFilters.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildChannelAgentConversationMarkerFilter, buildChannelAuxDeletionFilter, buildChannelAuxFilter, buildChannelReactionAuxFilter, @@ -43,3 +44,10 @@ test("buildChannelStructuralAuxFilter excludes reactions", () => { assert.deepEqual(filter["#e"], IDS); assert.equal("#h" in filter, false); }); + +test("buildChannelAgentConversationMarkerFilter scopes task markers by channel and references", () => { + const filter = buildChannelAgentConversationMarkerFilter(CHANNEL, IDS); + assert.deepEqual(filter.kinds, [40004, 40010]); + assert.deepEqual(filter["#h"], [CHANNEL]); + assert.deepEqual(filter["#e"], IDS); +}); diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index d0c7e7938..6916add0c 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -2,6 +2,7 @@ import { CHANNEL_AUX_EVENT_KINDS, CHANNEL_EVENT_KINDS, CHANNEL_TIMELINE_CONTENT_KINDS, + CHANNEL_TIMELINE_STATE_KINDS, HOME_MENTION_EVENT_KINDS, KIND_DELETION, KIND_NIP29_DELETE_EVENT, @@ -41,9 +42,9 @@ export function buildChannelFilter( } /** - * History filter for cold-load and scrollback: message kinds *only*, so the - * `limit` budget buys visible message depth. Auxiliary events (reactions, - * edits, deletions) are backfilled separately by `#e` reference via + * History filter for cold-load and scrollback: message kinds plus lightweight + * timeline state markers. Auxiliary events (reactions, edits, deletions) are + * backfilled separately by `#e` reference via * {@link buildChannelStructuralAuxFilter} and * {@link buildChannelReactionAuxFilter}, and arrive for future messages * through the live subscription ({@link buildChannelFilter}, which keeps the @@ -55,7 +56,7 @@ export function buildChannelHistoryFilter( until?: number, ): RelaySubscriptionFilter { const filter: RelaySubscriptionFilter = { - kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS], + kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS, ...CHANNEL_TIMELINE_STATE_KINDS], "#h": [channelId], limit, }; @@ -96,6 +97,18 @@ export function buildChannelStructuralAuxFilter( ]); } +export function buildChannelAgentConversationMarkerFilter( + channelId: string, + referencedEventIds: string[], +): RelaySubscriptionFilter { + return { + kinds: [...CHANNEL_TIMELINE_STATE_KINDS], + "#h": [channelId], + "#e": referencedEventIds, + limit: MAX_HISTORICAL_LIMIT, + }; +} + /** * Reactions-only filter for the message rows the GUI is currently rendering. * Keep this separate from structural aux backfill so the slow kind:5 deletion