Fix task conversation review issues

This commit is contained in:
klopez4212
2026-06-29 21:42:49 +01:00
parent 24bc99f2a6
commit 35e2c32865
9 changed files with 136 additions and 18 deletions

View File

@@ -769,6 +769,9 @@ pub struct ThreadTags {
pub root_event_id: Option<String>,
/// Parent event ID (hex). For direct replies to root, equals root.
pub parent_event_id: Option<String>,
/// Dedicated agent-conversation anchor, when a client asks follow-ups to
/// stay attached to a selected task instead of the flat thread root.
pub agent_reply_event_id: Option<String>,
/// Mentioned pubkeys from `p` tags (hex).
pub mentioned_pubkeys: Vec<String>,
}
@@ -779,6 +782,8 @@ pub struct ThreadTags {
/// - Find an `e` tag with `root` marker → its value is `root_event_id`
/// - Find an `e` tag with `reply` marker → its value is `parent_event_id`
/// - If only `reply` marker found (direct reply to root), root == parent
/// - Find a client task marker (`["client", "agent-conversation", id]`) →
/// its value is `agent_reply_event_id`
/// - `p` tags → mentioned pubkeys
///
/// NOTE: Only handles NIP-10 marker-based format (preferred). The deprecated
@@ -787,6 +792,7 @@ pub struct ThreadTags {
pub fn parse_thread_tags(event: &Event) -> ThreadTags {
let mut root = None;
let mut reply = None;
let mut agent_reply = None;
let mut mentions = Vec::new();
for tag in event.tags.iter() {
@@ -804,6 +810,12 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
Some("p") if parts.len() >= 2 => {
mentions.push(parts[1].clone());
}
Some("client")
if parts.len() >= 3
&& parts.get(1).map(|value| value.as_str()) == Some("agent-conversation") =>
{
agent_reply = parts.get(2).cloned();
}
_ => {}
}
}
@@ -820,6 +832,7 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
ThreadTags {
root_event_id,
parent_event_id,
agent_reply_event_id: agent_reply,
mentioned_pubkeys: mentions,
}
}
@@ -1061,6 +1074,9 @@ pub(crate) fn format_event_block(
if let Some(ref r) = thread.root_event_id {
parsed_parts.push(format!("root={r}"));
}
if let Some(ref agent_reply) = thread.agent_reply_event_id {
parsed_parts.push(format!("agent_reply={agent_reply}"));
}
if !thread.mentioned_pubkeys.is_empty() {
parsed_parts.push(format!(
"mentions=[{}]",
@@ -1139,8 +1155,9 @@ fn turn_is_human_facing(
/// Resolve the `--reply-to` anchor for a non-DM turn.
///
/// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]):
/// - 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
/// - dedicated task → the selected agent reply anchor
/// - 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
///
/// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply
/// (intentional for agent coordination).
@@ -1155,8 +1172,9 @@ fn resolve_reply_anchor(
}
Some(
thread_tags
.root_event_id
.agent_reply_event_id
.clone()
.or_else(|| thread_tags.root_event_id.clone())
.unwrap_or_else(|| triggering_event_id.to_string()),
)
}
@@ -2771,6 +2789,26 @@ mod tests {
assert_eq!(tags.parent_event_id.as_deref(), Some("parent456"));
}
#[test]
fn test_parse_thread_tags_agent_conversation_client_anchor() {
let event = make_event_with_tags(
"dedicated task follow-up",
vec![
vec!["e".into(), "root123".into(), "".into(), "root".into()],
vec!["e".into(), "parent456".into(), "".into(), "reply".into()],
vec![
"client".into(),
"agent-conversation".into(),
"agent789".into(),
],
],
);
let tags = parse_thread_tags(&event);
assert_eq!(tags.root_event_id.as_deref(), Some("root123"));
assert_eq!(tags.parent_event_id.as_deref(), Some("parent456"));
assert_eq!(tags.agent_reply_event_id.as_deref(), Some("agent789"));
}
#[test]
fn test_parse_thread_tags_with_mentions() {
let event = make_event_with_tags(
@@ -3106,6 +3144,7 @@ mod tests {
fn thread_tags(root: Option<&str>, mentions: &[&str]) -> ThreadTags {
ThreadTags {
agent_reply_event_id: None,
root_event_id: root.map(str::to_string),
parent_event_id: root.map(str::to_string),
mentioned_pubkeys: mentions.iter().map(|s| s.to_string()).collect(),
@@ -3741,6 +3780,46 @@ mod tests {
);
}
#[test]
fn test_reply_instruction_prefers_agent_conversation_anchor() {
let ch = Uuid::new_v4();
let root_id = "a".repeat(64);
let parent_id = "b".repeat(64);
let agent_reply_id = "c".repeat(64);
let event = make_event_with_tags(
"@bot keep going",
vec![
vec!["e".into(), root_id.clone(), "".into(), "root".into()],
vec!["e".into(), parent_id.clone(), "".into(), "reply".into()],
vec![
"client".into(),
"agent-conversation".into(),
agent_reply_id.clone(),
],
],
);
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
event,
prompt_tag: "@mention".into(),
received_at: Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {agent_reply_id}")),
"dedicated task follow-up should anchor agent reply to the selected task"
);
assert!(
!prompt.contains(&format!("--reply-to {root_id}")),
"dedicated task follow-up should not fall back to the flat thread root"
);
}
#[test]
fn test_reply_instruction_present_for_dm_thread_reply() {
let ch = Uuid::new_v4();

View File

@@ -91,8 +91,8 @@ const overrides = new Map([
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
// parameter plumbing, not generic debt growth. Approved override; still
// queued to split.
// parameter plumbing, plus continued-agent-conversations client task-anchor
// tags on message sends. Approved override; still queued to split.
["src/shared/api/tauri.ts", 1235],
// harness-persona-sync feature growth, queued to split in the resolver-unify
// refactor followup. discovery.rs is dominated by the new test module

View File

@@ -283,6 +283,7 @@ pub async fn send_channel_message(
media_tags: Option<Vec<Vec<String>>>,
emoji_tags: Option<Vec<Vec<String>>>,
mention_tags: Option<Vec<Vec<String>>>,
client_tags: Option<Vec<Vec<String>>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
state: State<'_, AppState>,
@@ -294,7 +295,11 @@ pub async fn send_channel_message(
let media = media_tags.unwrap_or_default();
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
let client = client_tags.unwrap_or_default();
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
if kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE && !client.is_empty() {
return Err("client tags are only supported on stream messages".into());
}
let mut resolved_root: Option<String> = None;
@@ -330,7 +335,7 @@ pub async fn send_channel_message(
}
None => None,
};
events::build_message(
events::build_message_with_client_tags(
channel_uuid,
content.trim(),
thread_ref.as_ref(),
@@ -338,6 +343,7 @@ pub async fn send_channel_message(
&media,
&emoji,
&mention_refs_only,
&client,
)?
}
};

View File

@@ -556,6 +556,9 @@ export function AgentConversationScreen({
mediaTags?: string[][],
) => {
await sendMessageMutation.mutateAsync({
clientTags: [
["client", "agent-conversation", conversation.agentReply.id],
],
content,
mediaTags,
mentionPubkeys: buildAgentConversationMentionPubkeys({
@@ -565,7 +568,12 @@ export function AgentConversationScreen({
parentEventId: replyParentEventId,
});
},
[autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation],
[
autoRoutedAgentPubkeys,
conversation.agentReply.id,
replyParentEventId,
sendMessageMutation,
],
);
const isComposerDisabled =

View File

@@ -126,6 +126,7 @@ export function createOptimisticMessage(
mentionPubkeys: string[] = [],
parentEventId: string | null = null,
mediaTags: string[][] = [],
clientTags: string[][] = [],
): RelayEvent {
const localKey = `optimistic-${crypto.randomUUID()}`;
const tags: string[][] = [];
@@ -154,6 +155,9 @@ export function createOptimisticMessage(
for (const tag of mediaTags) {
tags.push(tag);
}
for (const tag of clientTags) {
tags.push(tag);
}
return {
id: localKey,
@@ -341,10 +345,12 @@ export function useSendMessageMutation(
mentionPubkeys?: string[];
parentEventId?: string | null;
mediaTags?: string[][];
clientTags?: string[][];
},
MessageQueryContext | undefined
>({
mutationFn: async ({
clientTags,
content,
mentionPubkeys,
parentEventId,
@@ -371,7 +377,12 @@ export function useSendMessageMutation(
// Messages carrying media OR custom-emoji tags MUST go through REST so
// the relay's tag validation runs. The WebSocket path emits no extra
// tags, so emoji-only messages would otherwise lose their emoji tag.
if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) {
if (
parentEventId ||
imetaTags.length > 0 ||
emojiTags.length > 0 ||
(clientTags?.length ?? 0) > 0
) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>(
channelMessagesKey(channel.id),
@@ -385,6 +396,7 @@ export function useSendMessageMutation(
undefined,
emojiTags,
mentionTags,
clientTags,
);
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
@@ -423,6 +435,7 @@ export function useSendMessageMutation(
...imetaTags,
...emojiTags,
...mentionTags,
...(clientTags ?? []),
],
content: content.trim(),
sig: "",
@@ -436,7 +449,13 @@ export function useSendMessageMutation(
mentionTags,
);
},
onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => {
onMutate: async ({
clientTags,
content,
mentionPubkeys,
parentEventId,
mediaTags,
}) => {
if (!channel || !identity || channel.channelType === "forum") {
return undefined;
}
@@ -454,6 +473,7 @@ export function useSendMessageMutation(
mentionPubkeys ?? [],
parentEventId ?? null,
mediaTags ?? [],
clientTags ?? [],
);
queryClient.setQueryData<RelayEvent[]>(

View File

@@ -883,8 +883,7 @@ export const MessageRow = React.memo(
prev.onCollapseDescendants === next.onCollapseDescendants &&
prev.onCollapseDescendantsHoverChange ===
next.onCollapseDescendantsHoverChange &&
Boolean(prev.onOpenAgentConversation) ===
Boolean(next.onOpenAgentConversation) &&
prev.onOpenAgentConversation === next.onOpenAgentConversation &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewContext === next.videoReviewContext,

View File

@@ -265,6 +265,7 @@ const MessageTimelineBase = React.forwardRef<
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
resetKey: scrollRouteKey,
scrollContainerRef,
targetMessageId,
});

View File

@@ -40,6 +40,8 @@ type UseAnchoredScrollOptions = {
contentRef: React.RefObject<HTMLDivElement | null>;
/** Resets when changed; lets us drop anchor + scroll state across channels. */
channelId?: string | null;
/** Resets when changed; includes channel plus route-specific layout state. */
resetKey?: string | null;
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
isLoading: boolean;
/** Source of truth for the rendered list. Used to detect new-at-bottom
@@ -145,6 +147,7 @@ export function useAnchoredScroll({
scrollContainerRef,
contentRef,
channelId,
resetKey = channelId ?? null,
isLoading,
messages,
@@ -181,10 +184,10 @@ export function useAnchoredScroll({
// guard runs on a native scroll event, outside React's render cycle.
const settlingRef = React.useRef(false);
// Reset everything when the channel changes — the layout effect that runs
// immediately after this reset is responsible for either jumping to bottom
// or to the target message for the new channel.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount).
// Reset everything when the route's scroll identity changes — the layout
// effect that runs immediately after this reset is responsible for either
// jumping to bottom or to the target message for the new view.
// biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is intentionally the sole trigger — it includes channel identity plus route-specific layout state.
React.useLayoutEffect(() => {
anchorRef.current = { kind: "at-bottom" };
setIsAtBottom(true);
@@ -205,7 +208,7 @@ export function useAnchoredScroll({
cancelAnimationFrame(mountPinRafIdRef.current);
mountPinRafIdRef.current = null;
}
}, [channelId]);
}, [resetKey]);
const scrollToBottomImperative = React.useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -454,7 +457,7 @@ export function useAnchoredScroll({
// mid-history, native scroll anchoring (overflow-anchor) holds the reading
// row across the reflow, so there's nothing to do.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
// biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on route identity changes the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous route's detached node and re-observe the live one.
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
@@ -467,7 +470,7 @@ export function useAnchoredScroll({
});
observer.observe(content);
return () => observer.disconnect();
}, [channelId, contentRef, scrollContainerRef]);
}, [resetKey, contentRef, scrollContainerRef]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from

View File

@@ -717,6 +717,7 @@ export async function sendChannelMessage(
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
clientTags?: string[][],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -727,6 +728,7 @@ export async function sendChannelMessage(
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
clientTags: clientTags ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
},