Fix task scoping and agent start reconciliation

This commit is contained in:
klopez4212
2026-06-30 08:15:44 +01:00
parent f9de7f57ed
commit cb773d0d48
7 changed files with 208 additions and 18 deletions
+1 -4
View File
@@ -1385,10 +1385,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
// there. DMs are always 1:1 with a human, so they always anchor.
let sender_pubkey = last_event.event.pubkey.to_hex();
let reply_anchor = if is_dm {
thread_tags
.root_event_id
.is_some()
.then(|| last_event.event.id.to_hex())
thread_tags.root_event_id.clone()
} else {
resolve_reply_anchor(
&sender_pubkey,
@@ -0,0 +1,32 @@
use tauri::AppHandle;
use crate::{app_state::AppState, managed_agents::load_managed_agents};
use super::agents::ProfileReconcileData;
pub(super) fn refresh_auth_tag(
app: &AppHandle,
state: &AppState,
pubkey: &str,
data: &mut ProfileReconcileData,
) {
let result = (|| -> 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}"
);
}
}
+4 -4
View File
@@ -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 {
+1
View File
@@ -1,6 +1,7 @@
mod agent_config;
mod agent_discovery;
mod agent_models;
mod agent_profile_reconcile;
mod agent_settings;
mod agents;
mod canvas;
@@ -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"]);
});
@@ -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
);
}
@@ -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(