Add agent conversation domain model

This commit is contained in:
klopez4212
2026-06-30 13:37:04 +01:00
parent aba7bdf45c
commit 2e4c46f55f
6 changed files with 2286 additions and 4 deletions

View 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;
}

View 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);
}

View 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",
]);
});

View 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;
}

View File

@@ -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);
});

View File

@@ -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