diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 23491d002c..8a9430f1bc 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2177,6 +2177,11 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Option { + let event_id = obj + .get("id") + .or_else(|| obj.get("event_id")) + .and_then(|v| v.as_str()) + .map(str::to_string); let content = obj.get("content").and_then(|v| v.as_str())?; let pubkey = obj .get("pubkey") @@ -2197,6 +2202,7 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { .unwrap_or_else(|| "unknown".to_string()); Some(ContextMessage { + event_id, pubkey: pubkey.to_string(), timestamp, content: content.to_string(), @@ -2817,6 +2823,7 @@ mod tests { }; let context = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: None, pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 1ba809eb5e..94b72d0316 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -936,6 +936,7 @@ pub enum ConversationContext { /// A single message in a conversation context section. #[derive(Debug, Clone)] pub struct ContextMessage { + pub event_id: Option, pub pubkey: String, pub timestamp: String, pub content: String, @@ -1152,10 +1153,35 @@ fn turn_is_human_facing( thread_tags.mentioned_pubkeys.iter().any(|pk| !is_agent(pk)) } +fn context_contains_event_id(context: Option<&ConversationContext>, event_id: &str) -> bool { + let messages = match context { + Some(ConversationContext::Thread { messages, .. }) + | Some(ConversationContext::Dm { messages, .. }) => messages, + None => return false, + }; + + messages + .iter() + .any(|message| message.event_id.as_deref() == Some(event_id)) +} + +fn resolve_valid_agent_conversation_anchor( + thread_tags: &ThreadTags, + conversation_context: Option<&ConversationContext>, +) -> Option { + let agent_reply_event_id = thread_tags.agent_reply_event_id.as_deref()?; + if context_contains_event_id(conversation_context, agent_reply_event_id) { + return Some(agent_reply_event_id.to_string()); + } + + None +} + /// Resolve the `--reply-to` anchor for a non-DM turn. /// /// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]): -/// - dedicated task → the selected agent reply anchor +/// - dedicated task → the selected agent reply anchor, after validating it +/// exists in the fetched context for this thread/task /// - in a thread → the thread ROOT, keeping the reply flat at layer 1 /// - top-level → the triggering event id, which becomes the new thread root /// @@ -1165,15 +1191,14 @@ fn resolve_reply_anchor( sender_pubkey: &str, thread_tags: &ThreadTags, triggering_event_id: &str, + conversation_context: Option<&ConversationContext>, profile_lookup: Option<&PromptProfileLookup>, ) -> Option { if !turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup) { return None; } Some( - thread_tags - .agent_reply_event_id - .clone() + resolve_valid_agent_conversation_anchor(thread_tags, conversation_context) .or_else(|| thread_tags.root_event_id.clone()) .unwrap_or_else(|| triggering_event_id.to_string()), ) @@ -1409,6 +1434,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec { + 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?", diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index 422e9a4289..02eb23d867 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -659,7 +659,7 @@ export function getHiddenAgentConversationMessageIds( const cutoffByThreadRootId = new Map< string, { - anchorIndex: number; + anchorIndex: number | null; startedAt: number; } >(); @@ -667,27 +667,41 @@ export function getHiddenAgentConversationMessageIds( const anchorMessage = messageById.get(marker.agentReplyId); const anchorIndex = messageIndexById.get(marker.agentReplyId); if (!anchorMessage || anchorIndex === undefined) { - continue; - } + 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 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 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: anchorIndex ?? null, startedAt: marker.startedAt, }; const isEarlier = - current === undefined || candidate.anchorIndex < current.anchorIndex; + 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); } @@ -709,14 +723,14 @@ export function getHiddenAgentConversationMessageIds( } const messageIndex = messageIndexById.get(message.id); - if (messageIndex !== undefined) { + if (messageIndex !== undefined && cutoff.anchorIndex !== null) { if (messageIndex > cutoff.anchorIndex) { hiddenIds.add(message.id); } continue; } - if (message.createdAt > cutoff.startedAt) { + if (message.createdAt >= cutoff.startedAt) { hiddenIds.add(message.id); } }