diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 23bb6e105..fdefe883c 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1385,10 +1385,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + data.auth_tag = record.auth_tag.clone(); + Ok(()) + })(); + + if let Err(error) = result { + eprintln!( + "buzz-desktop: profile reconciliation using pre-start auth tag for agent {pubkey}: {error}" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 4ea7abf0b..d437169e2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1000,8 +1000,7 @@ pub async fn start_managed_agent( } // Collect backend info under lock; async preflight/spawn happens below. - // Also snapshot profile reconciliation data for the background task. - let (target, reconcile_data) = { + let (target, mut reconcile_data) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -1103,9 +1102,10 @@ pub async fn start_managed_agent( // ── Profile reconciliation (fire-and-forget) ──────────────────────────── // On successful start, spawn a background task to ensure the agent's kind:0 // profile is published on the relay. This self-heals cases where the initial - // profile sync at creation time failed silently. For legacy records (pre-PR-921) - // with no persisted avatar, this also backfills the avatar from the relay. + // profile sync at creation time failed silently. if result.is_ok() { + use super::agent_profile_reconcile as reconcile; + reconcile::refresh_auth_tag(&app, &state, &pubkey, &mut reconcile_data); let reconcile_pubkey = pubkey.clone(); let reconcile_app = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 445fe2956..9959583a3 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ mod agent_config; mod agent_discovery; mod agent_models; +mod agent_profile_reconcile; mod agent_settings; mod agents; mod canvas; diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 696444633..f8103e4e0 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -13,6 +13,7 @@ import { readPersistedAgentConversations, writePersistedAgentConversations, } from "./agentConversations.ts"; +import { isConversationMessage } from "./ui/AgentConversationScreen.helpers.ts"; function message({ body, createdAt, id, pubkey = "human" }) { return { @@ -491,3 +492,75 @@ test("continued conversation markers keep later task anchors visible", () => { 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"]); +}); diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts index dba599794..9c933bc60 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -1,4 +1,7 @@ -import type { AgentConversation } from "@/features/agents/agentConversations"; +import type { + AgentConversation, + AgentConversationMarker, +} from "@/features/agents/agentConversations"; import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import type { TimelineMessage, @@ -74,12 +77,90 @@ export function formatAgentParticipantNames( export function isConversationMessage( message: TimelineMessage, conversation: AgentConversation, + markers: readonly AgentConversationMarker[] = [], + messages: readonly TimelineMessage[] = [], ) { - return ( + if ( message.id === conversation.threadRootId || - message.id === conversation.agentReply.id || - message.rootId === conversation.threadRootId || - message.parentId === conversation.threadRootId + message.id === conversation.parentMessage?.id || + message.id === conversation.agentReply.id + ) { + return true; + } + + const messageThreadRootId = message.rootId ?? message.parentId ?? null; + if (messageThreadRootId !== conversation.threadRootId) { + return false; + } + + const orderedThreadMessages = + messages.length > 0 + ? messages.filter( + (candidate) => + candidate.id === conversation.threadRootId || + candidate.rootId === conversation.threadRootId || + candidate.parentId === conversation.threadRootId, + ) + : []; + const messageIndexById = new Map( + orderedThreadMessages.map((candidate, index) => [candidate.id, index]), + ); + const anchorIndex = messageIndexById.get(conversation.agentReply.id); + const messageIndex = messageIndexById.get(message.id); + + if (anchorIndex !== undefined && messageIndex !== undefined) { + if (messageIndex < anchorIndex) { + return false; + } + + let nextAnchorIndex = Number.POSITIVE_INFINITY; + for (const marker of markers) { + if ( + marker.channelId !== conversation.channelId || + marker.threadRootId !== conversation.threadRootId || + marker.agentReplyId === conversation.agentReply.id + ) { + continue; + } + + const markerAnchorIndex = messageIndexById.get(marker.agentReplyId); + if ( + markerAnchorIndex !== undefined && + markerAnchorIndex > anchorIndex && + markerAnchorIndex < nextAnchorIndex + ) { + nextAnchorIndex = markerAnchorIndex; + } + } + + return messageIndex < nextAnchorIndex; + } + + const currentMarker = + markers.find( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId === conversation.agentReply.id, + ) ?? null; + const selectedStartedAt = + currentMarker?.startedAt ?? conversation.agentReply.createdAt; + if (message.createdAt < selectedStartedAt) { + return false; + } + + const nextMarkerStartedAt = markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id && + marker.startedAt > selectedStartedAt, + ) + .sort((left, right) => left.startedAt - right.startedAt)[0]?.startedAt; + + return ( + nextMarkerStartedAt === undefined || message.createdAt < nextMarkerStartedAt ); } diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index d9bffae54..1f77560a5 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -128,6 +128,10 @@ export function AgentConversationScreen({ const sendMessageMutation = useSendMessageMutation(channel, currentIdentity); const relayMessages = messagesQuery.data ?? []; + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(relayMessages), + [relayMessages], + ); const { getMessageReadAt, isThreadMuted, @@ -211,7 +215,12 @@ export function AgentConversationScreen({ profiles, ); const scoped = formatted.filter((message) => - isConversationMessage(message, conversation), + isConversationMessage( + message, + conversation, + agentConversationMarkers, + formatted, + ), ); const sourceMessages = scoped.length > 0 @@ -231,6 +240,7 @@ export function AgentConversationScreen({ ); }, [ channel, + agentConversationMarkers, conversation, currentIdentity?.pubkey, currentProfile?.avatarUrl, @@ -477,10 +487,6 @@ export function AgentConversationScreen({ React.useEffect(() => { lastPublishedThreadRecapRef.current = null; }, [conversation.id]); - const agentConversationMarkers = React.useMemo( - () => buildAgentConversationMarkers(relayMessages), - [relayMessages], - ); const currentConversationMarker = React.useMemo( () => agentConversationMarkers.find(