mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Add agent conversation domain model
This commit is contained in:
202
desktop/src/features/agents/agentConversationRecap.ts
Normal file
202
desktop/src/features/agents/agentConversationRecap.ts
Normal file
@@ -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<string, (typeof agentMessages)[number]>();
|
||||
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;
|
||||
}
|
||||
582
desktop/src/features/agents/agentConversationTitles.ts
Normal file
582
desktop/src/features/agents/agentConversationTitles.ts
Normal file
@@ -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<string, { display: string; score: number }>();
|
||||
|
||||
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<string, TimelineMessage>();
|
||||
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);
|
||||
}
|
||||
738
desktop/src/features/agents/agentConversations.test.mjs
Normal file
738
desktop/src/features/agents/agentConversations.test.mjs
Normal file
@@ -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",
|
||||
]);
|
||||
});
|
||||
739
desktop/src/features/agents/agentConversations.ts
Normal file
739
desktop/src/features/agents/agentConversations.ts
Normal file
@@ -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<Channel, "id" | "name">;
|
||||
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<string> | 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<string>();
|
||||
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<string> {
|
||||
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<string>,
|
||||
): 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<string, unknown> {
|
||||
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<string, AgentConversation>();
|
||||
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<string, AgentConversation>();
|
||||
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<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} 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<string, AgentConversationMarker>();
|
||||
|
||||
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<RelayEvent> {
|
||||
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<string> {
|
||||
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<string, Set<string>>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user