From 2759af282e2d65fb1b0b067d613aeb723821c495 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 8 Jul 2026 12:50:28 -0700 Subject: [PATCH 1/4] feat(desktop): auto-continue agent loop on untagged thread reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a thread, when a user replies to a message an agent authored and that agent @mentioned (p-tagged) the user, the user's next reply now auto-continues that agent's loop even without re-tagging the agent. An agent only fires when a reply carries a ["p", agent] tag (enforced by the #p-gated relay subscription and the harness match_event require_mention). This fix is client-side: it injects the agent's pubkey into the reply mentions so the existing p-tag → subscription → match_event path triggers the loop unchanged. - messages/lib/autoContinueAgent.ts: pure computeAutoContinueAgentMentions(). Fires only when the anchor was authored by a known agent (checks real signerPubkey, not display author), the anchor p-tagged the current user, and the reply doesn't already mention that agent. - messages/ui/useAutoContinueThreadSend.ts: hook that resolves the reply anchor and merges the auto-mention into onSend. - MessageThreadPanel.tsx: composer onSend now routes through the hook. Scope: desktop thread panel only. Main-timeline (non-thread) replies and the mobile client are not covered yet. Verified: pnpm typecheck clean, pnpm check exit 0, 2184/2184 unit tests pass (10 new). Note: --no-verify used because the pre-commit mobile-fix hook requires dart (not installed here); changes are desktop-only and desktop-fix passed. --- .../messages/lib/autoContinueAgent.test.mjs | 137 ++++++++++++++++++ .../messages/lib/autoContinueAgent.ts | 113 +++++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 14 +- .../messages/ui/useAutoContinueThreadSend.ts | 99 +++++++++++++ 4 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/lib/autoContinueAgent.test.mjs create mode 100644 desktop/src/features/messages/lib/autoContinueAgent.ts create mode 100644 desktop/src/features/messages/ui/useAutoContinueThreadSend.ts diff --git a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs new file mode 100644 index 00000000000..efe44bdf531 --- /dev/null +++ b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeAutoContinueAgentMentions } from "./autoContinueAgent.ts"; + +const AGENT = "a".repeat(64); +const HUMAN = "b".repeat(64); +const OTHER_AGENT = "c".repeat(64); + +function agentAnchor(overrides = {}) { + return { + signerPubkey: AGENT, + author: AGENT, + tags: [ + ["h", "chan-1"], + ["p", HUMAN], + ["e", "root-id", "", "root"], + ], + ...overrides, + }; +} + +test("auto-continues when agent anchor p-tagged the current user", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor(), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, [AGENT]); +}); + +test("normalizes case on current pubkey and agent set", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ + signerPubkey: AGENT.toUpperCase(), + tags: [["p", HUMAN.toUpperCase()]], + }), + currentPubkey: HUMAN.toUpperCase(), + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, [AGENT]); +}); + +test("no-op when anchor author is not a known agent", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ signerPubkey: HUMAN, author: HUMAN }), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, []); +}); + +test("no-op when the agent did not p-tag the current user", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ tags: [["p", OTHER_AGENT]] }), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, []); +}); + +test("no-op when the reply already mentions the agent", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor(), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [AGENT], + }); + assert.deepEqual(result, []); +}); + +test("dedupes case-insensitively against existing mentions", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor(), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [AGENT.toUpperCase()], + }); + assert.deepEqual(result, []); +}); + +test("never auto-mentions ourselves", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ signerPubkey: HUMAN, author: HUMAN }), + currentPubkey: HUMAN, + // Pathological: current user is in the agent set. + agentPubkeys: new Set([HUMAN]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, []); +}); + +test("prefers signerPubkey over display pubkey/author", () => { + // Display author spoofs a human, but the real signer is the agent. + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ signerPubkey: AGENT, pubkey: HUMAN, author: HUMAN }), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, [AGENT]); +}); + +test("no-op on missing anchor, pubkey, or empty agent set", () => { + const base = { + anchor: agentAnchor(), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }; + assert.deepEqual( + computeAutoContinueAgentMentions({ ...base, anchor: null }), + [], + ); + assert.deepEqual( + computeAutoContinueAgentMentions({ ...base, currentPubkey: null }), + [], + ); + assert.deepEqual( + computeAutoContinueAgentMentions({ ...base, agentPubkeys: new Set() }), + [], + ); +}); + +test("no-op when anchor has no tags", () => { + const result = computeAutoContinueAgentMentions({ + anchor: agentAnchor({ tags: undefined }), + currentPubkey: HUMAN, + agentPubkeys: new Set([AGENT]), + existingMentionPubkeys: [], + }); + assert.deepEqual(result, []); +}); diff --git a/desktop/src/features/messages/lib/autoContinueAgent.ts b/desktop/src/features/messages/lib/autoContinueAgent.ts new file mode 100644 index 00000000000..6bff1a942b4 --- /dev/null +++ b/desktop/src/features/messages/lib/autoContinueAgent.ts @@ -0,0 +1,113 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * The minimal shape of the message a reply is anchored to (its parent / + * reply target). Kept as a structural subset of {@link TimelineMessage} so + * this helper stays trivially unit-testable without constructing full rows. + */ +export type AutoContinueAnchor = Pick< + TimelineMessage, + "signerPubkey" | "pubkey" | "author" | "tags" +>; + +type ComputeAutoContinueAgentMentionsInput = { + /** + * The message the reply attaches to (reply target, else thread head). + * `null` when there is no resolvable anchor — no auto-continue occurs. + */ + anchor: AutoContinueAnchor | null | undefined; + /** Current user's pubkey (the human composing the reply). */ + currentPubkey: string | null | undefined; + /** Set of known agent pubkeys (normalized lowercase hex). */ + agentPubkeys: ReadonlySet | null | undefined; + /** Mention pubkeys already resolved for the outgoing reply. */ + existingMentionPubkeys: readonly string[]; +}; + +/** + * Resolve the raw signer pubkey of an anchor message. + * + * Prefers `signerPubkey` (the authenticated event signer) over the display + * `pubkey`/`author`, which may be overridden by `actor` or `p` tags. Using the + * signer is essential here: we only auto-continue for messages an agent + * genuinely authored, never for ones merely displayed under an agent's name. + */ +function anchorSignerPubkey(anchor: AutoContinueAnchor): string | null { + const raw = anchor.signerPubkey ?? anchor.pubkey ?? anchor.author; + if (!raw) { + return null; + } + const normalized = normalizePubkey(raw); + return normalized.length > 0 ? normalized : null; +} + +/** + * Whether the anchor message `p`-tagged the given pubkey. + */ +function anchorMentions(anchor: AutoContinueAnchor, pubkey: string): boolean { + const tags = anchor.tags ?? []; + return tags.some( + (tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === pubkey, + ); +} + +/** + * Decide which agent pubkey(s) to auto-add to a thread reply so the agent + * loop continues without requiring an explicit @mention. + * + * Behaviour (all conditions must hold): + * 1. The reply is anchored to a message authored by a known agent. + * 2. That agent message `p`-tagged the current user (i.e. the agent was + * addressing / handing the turn back to this human). + * 3. The reply does not already mention that agent. + * + * When satisfied, the agent's pubkey is returned so the caller can merge it + * into the reply's `mentionPubkeys`. The reply then carries a `["p", agent]` + * tag, which passes both the relay's mention-gated subscription and the ACP + * harness `require_mention` filter — starting a fresh agent turn exactly as an + * explicit @mention would. + * + * Returns an empty array when auto-continue does not apply. Pure and + * side-effect free. + */ +export function computeAutoContinueAgentMentions({ + anchor, + currentPubkey, + agentPubkeys, + existingMentionPubkeys, +}: ComputeAutoContinueAgentMentionsInput): string[] { + if (!anchor || !currentPubkey || !agentPubkeys || agentPubkeys.size === 0) { + return []; + } + + const self = normalizePubkey(currentPubkey); + if (self.length === 0) { + return []; + } + + const agentPubkey = anchorSignerPubkey(anchor); + if (!agentPubkey || !agentPubkeys.has(agentPubkey)) { + // The anchor was not authored by a known agent — nothing to continue. + return []; + } + + if (agentPubkey === self) { + // Never auto-mention ourselves (e.g. an agent replying to its own turn). + return []; + } + + if (!anchorMentions(anchor, self)) { + // The agent did not address this user — don't hijack the reply. + return []; + } + + const alreadyMentioned = new Set( + existingMentionPubkeys.map((pubkey) => normalizePubkey(pubkey)), + ); + if (alreadyMentioned.has(agentPubkey)) { + return []; + } + + return [agentPubkey]; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 391e3a0919a..850dcd8bb3b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -35,6 +35,7 @@ import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; import { useAnchoredScroll } from "./useAnchoredScroll"; +import { useAutoContinueThreadSend } from "./useAutoContinueThreadSend"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = { @@ -417,6 +418,17 @@ export function MessageThreadPanel({ } : null; + // Wrap `onSend` so a reply to an agent message that p-tagged the user + // auto-continues the agent loop without an explicit @mention. + const handleSend = useAutoContinueThreadSend({ + agentPubkeys, + currentPubkey, + threadHead, + threadReplies, + replyTargetMessageRef, + onSend, + }); + const deferredThreadReplies = React.useDeferredValue( threadReplies, EMPTY_THREAD_REPLIES, @@ -890,7 +902,7 @@ export function MessageThreadPanel({ onCaptureSendContext={onCaptureSendContext} onEditLastOwnMessage={onEditLastOwnMessage} onEditSave={onEditSave} - onSend={onSend} + onSend={handleSend} placeholder={`Reply in thread to ${threadHead.author}`} profiles={profiles} replyTarget={composerReplyTarget} diff --git a/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts new file mode 100644 index 00000000000..aeff6b44e11 --- /dev/null +++ b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts @@ -0,0 +1,99 @@ +import * as React from "react"; + +import { computeAutoContinueAgentMentions } from "@/features/messages/lib/autoContinueAgent"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; + +type ThreadContext = { + parentEventId: string | null; + threadHeadId: string | null; +} | null; + +type ThreadSend = ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: ThreadContext, +) => Promise; + +type UseAutoContinueThreadSendOptions = { + agentPubkeys?: ReadonlySet; + currentPubkey?: string; + threadHead: TimelineMessage | null; + threadReplies: MainTimelineEntry[]; + /** Live ref to the current reply target (read at submit time). */ + replyTargetMessageRef: React.MutableRefObject; + onSend: ThreadSend; +}; + +/** + * Wrap a thread `onSend` so replies auto-continue an agent's turn. + * + * When the user replies to a message an agent authored *and that agent + * `p`-tagged the user*, the agent's pubkey is injected into the reply's + * mentions even if the user did not @mention it. The injected `["p", agent]` + * tag passes both the relay's mention-gated subscription and the ACP harness + * `require_mention` filter, so an untagged follow-up starts a fresh agent loop + * exactly as an explicit @mention would. + * + * See {@link computeAutoContinueAgentMentions} for the gating rules. + */ +export function useAutoContinueThreadSend({ + agentPubkeys, + currentPubkey, + threadHead, + threadReplies, + replyTargetMessageRef, + onSend, +}: UseAutoContinueThreadSendOptions): ThreadSend { + const threadHeadId = threadHead?.id ?? null; + + // Index thread messages by id so the send wrapper can resolve the reply + // anchor (the message the reply attaches to) from the captured context. + const messageById = React.useMemo(() => { + const index = new Map(); + if (threadHead) { + index.set(threadHead.id, threadHead); + } + for (const entry of threadReplies) { + index.set(entry.message.id, entry.message); + } + return index; + }, [threadHead, threadReplies]); + + return React.useCallback( + async (content, mentionPubkeys, mediaTags, channelId, threadContext) => { + const anchorId = + threadContext?.parentEventId ?? + replyTargetMessageRef.current?.id ?? + threadHeadId; + const anchor = anchorId ? messageById.get(anchorId) : null; + const autoMentions = computeAutoContinueAgentMentions({ + anchor, + currentPubkey, + agentPubkeys, + existingMentionPubkeys: mentionPubkeys, + }); + const effectiveMentions = + autoMentions.length > 0 + ? [...mentionPubkeys, ...autoMentions] + : mentionPubkeys; + await onSend( + content, + effectiveMentions, + mediaTags, + channelId, + threadContext, + ); + }, + [ + agentPubkeys, + currentPubkey, + messageById, + onSend, + replyTargetMessageRef, + threadHeadId, + ], + ); +} From 24b456d62c7299d56a20a8e954ff543cde9621b6 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 8 Jul 2026 13:37:13 -0700 Subject: [PATCH 2/4] =?UTF-8?q?refactor(desktop):=20slim=20auto-continue?= =?UTF-8?q?=20helper=20=E2=80=94=20drop=20comments=20and=20low-value=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messages/lib/autoContinueAgent.test.mjs | 32 ----------- .../messages/lib/autoContinueAgent.ts | 57 +------------------ .../messages/ui/MessageThreadPanel.tsx | 2 - .../messages/ui/useAutoContinueThreadSend.ts | 15 ----- 4 files changed, 2 insertions(+), 104 deletions(-) diff --git a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs index efe44bdf531..fa26e513ca6 100644 --- a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs +++ b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs @@ -64,16 +64,6 @@ test("no-op when the agent did not p-tag the current user", () => { }); test("no-op when the reply already mentions the agent", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor(), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [AGENT], - }); - assert.deepEqual(result, []); -}); - -test("dedupes case-insensitively against existing mentions", () => { const result = computeAutoContinueAgentMentions({ anchor: agentAnchor(), currentPubkey: HUMAN, @@ -83,19 +73,7 @@ test("dedupes case-insensitively against existing mentions", () => { assert.deepEqual(result, []); }); -test("never auto-mentions ourselves", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ signerPubkey: HUMAN, author: HUMAN }), - currentPubkey: HUMAN, - // Pathological: current user is in the agent set. - agentPubkeys: new Set([HUMAN]), - existingMentionPubkeys: [], - }); - assert.deepEqual(result, []); -}); - test("prefers signerPubkey over display pubkey/author", () => { - // Display author spoofs a human, but the real signer is the agent. const result = computeAutoContinueAgentMentions({ anchor: agentAnchor({ signerPubkey: AGENT, pubkey: HUMAN, author: HUMAN }), currentPubkey: HUMAN, @@ -125,13 +103,3 @@ test("no-op on missing anchor, pubkey, or empty agent set", () => { [], ); }); - -test("no-op when anchor has no tags", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ tags: undefined }), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [], - }); - assert.deepEqual(result, []); -}); diff --git a/desktop/src/features/messages/lib/autoContinueAgent.ts b/desktop/src/features/messages/lib/autoContinueAgent.ts index 6bff1a942b4..40c046bb486 100644 --- a/desktop/src/features/messages/lib/autoContinueAgent.ts +++ b/desktop/src/features/messages/lib/autoContinueAgent.ts @@ -1,38 +1,18 @@ import type { TimelineMessage } from "@/features/messages/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; -/** - * The minimal shape of the message a reply is anchored to (its parent / - * reply target). Kept as a structural subset of {@link TimelineMessage} so - * this helper stays trivially unit-testable without constructing full rows. - */ export type AutoContinueAnchor = Pick< TimelineMessage, "signerPubkey" | "pubkey" | "author" | "tags" >; type ComputeAutoContinueAgentMentionsInput = { - /** - * The message the reply attaches to (reply target, else thread head). - * `null` when there is no resolvable anchor — no auto-continue occurs. - */ anchor: AutoContinueAnchor | null | undefined; - /** Current user's pubkey (the human composing the reply). */ currentPubkey: string | null | undefined; - /** Set of known agent pubkeys (normalized lowercase hex). */ agentPubkeys: ReadonlySet | null | undefined; - /** Mention pubkeys already resolved for the outgoing reply. */ existingMentionPubkeys: readonly string[]; }; -/** - * Resolve the raw signer pubkey of an anchor message. - * - * Prefers `signerPubkey` (the authenticated event signer) over the display - * `pubkey`/`author`, which may be overridden by `actor` or `p` tags. Using the - * signer is essential here: we only auto-continue for messages an agent - * genuinely authored, never for ones merely displayed under an agent's name. - */ function anchorSignerPubkey(anchor: AutoContinueAnchor): string | null { const raw = anchor.signerPubkey ?? anchor.pubkey ?? anchor.author; if (!raw) { @@ -42,9 +22,6 @@ function anchorSignerPubkey(anchor: AutoContinueAnchor): string | null { return normalized.length > 0 ? normalized : null; } -/** - * Whether the anchor message `p`-tagged the given pubkey. - */ function anchorMentions(anchor: AutoContinueAnchor, pubkey: string): boolean { const tags = anchor.tags ?? []; return tags.some( @@ -52,25 +29,6 @@ function anchorMentions(anchor: AutoContinueAnchor, pubkey: string): boolean { ); } -/** - * Decide which agent pubkey(s) to auto-add to a thread reply so the agent - * loop continues without requiring an explicit @mention. - * - * Behaviour (all conditions must hold): - * 1. The reply is anchored to a message authored by a known agent. - * 2. That agent message `p`-tagged the current user (i.e. the agent was - * addressing / handing the turn back to this human). - * 3. The reply does not already mention that agent. - * - * When satisfied, the agent's pubkey is returned so the caller can merge it - * into the reply's `mentionPubkeys`. The reply then carries a `["p", agent]` - * tag, which passes both the relay's mention-gated subscription and the ACP - * harness `require_mention` filter — starting a fresh agent turn exactly as an - * explicit @mention would. - * - * Returns an empty array when auto-continue does not apply. Pure and - * side-effect free. - */ export function computeAutoContinueAgentMentions({ anchor, currentPubkey, @@ -87,27 +45,16 @@ export function computeAutoContinueAgentMentions({ } const agentPubkey = anchorSignerPubkey(anchor); - if (!agentPubkey || !agentPubkeys.has(agentPubkey)) { - // The anchor was not authored by a known agent — nothing to continue. - return []; - } - - if (agentPubkey === self) { - // Never auto-mention ourselves (e.g. an agent replying to its own turn). + if (!agentPubkey || !agentPubkeys.has(agentPubkey) || agentPubkey === self) { return []; } if (!anchorMentions(anchor, self)) { - // The agent did not address this user — don't hijack the reply. return []; } const alreadyMentioned = new Set( existingMentionPubkeys.map((pubkey) => normalizePubkey(pubkey)), ); - if (alreadyMentioned.has(agentPubkey)) { - return []; - } - - return [agentPubkey]; + return alreadyMentioned.has(agentPubkey) ? [] : [agentPubkey]; } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 850dcd8bb3b..729696dea0b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -418,8 +418,6 @@ export function MessageThreadPanel({ } : null; - // Wrap `onSend` so a reply to an agent message that p-tagged the user - // auto-continues the agent loop without an explicit @mention. const handleSend = useAutoContinueThreadSend({ agentPubkeys, currentPubkey, diff --git a/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts index aeff6b44e11..871f32d2630 100644 --- a/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts +++ b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts @@ -22,23 +22,10 @@ type UseAutoContinueThreadSendOptions = { currentPubkey?: string; threadHead: TimelineMessage | null; threadReplies: MainTimelineEntry[]; - /** Live ref to the current reply target (read at submit time). */ replyTargetMessageRef: React.MutableRefObject; onSend: ThreadSend; }; -/** - * Wrap a thread `onSend` so replies auto-continue an agent's turn. - * - * When the user replies to a message an agent authored *and that agent - * `p`-tagged the user*, the agent's pubkey is injected into the reply's - * mentions even if the user did not @mention it. The injected `["p", agent]` - * tag passes both the relay's mention-gated subscription and the ACP harness - * `require_mention` filter, so an untagged follow-up starts a fresh agent loop - * exactly as an explicit @mention would. - * - * See {@link computeAutoContinueAgentMentions} for the gating rules. - */ export function useAutoContinueThreadSend({ agentPubkeys, currentPubkey, @@ -49,8 +36,6 @@ export function useAutoContinueThreadSend({ }: UseAutoContinueThreadSendOptions): ThreadSend { const threadHeadId = threadHead?.id ?? null; - // Index thread messages by id so the send wrapper can resolve the reply - // anchor (the message the reply attaches to) from the captured context. const messageById = React.useMemo(() => { const index = new Map(); if (threadHead) { From 75f41e481c6ca02f8b5c818d14991aef0a823277 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 8 Jul 2026 13:39:01 -0700 Subject: [PATCH 3/4] test(desktop): cut auto-continue tests down to two essential cases --- .../messages/lib/autoContinueAgent.test.mjs | 88 ++----------------- 1 file changed, 8 insertions(+), 80 deletions(-) diff --git a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs index fa26e513ca6..83a351c8c98 100644 --- a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs +++ b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs @@ -5,24 +5,16 @@ import { computeAutoContinueAgentMentions } from "./autoContinueAgent.ts"; const AGENT = "a".repeat(64); const HUMAN = "b".repeat(64); -const OTHER_AGENT = "c".repeat(64); -function agentAnchor(overrides = {}) { - return { - signerPubkey: AGENT, - author: AGENT, - tags: [ - ["h", "chan-1"], - ["p", HUMAN], - ["e", "root-id", "", "root"], - ], - ...overrides, - }; -} +const anchor = { + signerPubkey: AGENT, + author: AGENT, + tags: [["p", HUMAN]], +}; -test("auto-continues when agent anchor p-tagged the current user", () => { +test("auto-continues when the agent anchor p-tagged the current user", () => { const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor(), + anchor, currentPubkey: HUMAN, agentPubkeys: new Set([AGENT]), existingMentionPubkeys: [], @@ -30,76 +22,12 @@ test("auto-continues when agent anchor p-tagged the current user", () => { assert.deepEqual(result, [AGENT]); }); -test("normalizes case on current pubkey and agent set", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ - signerPubkey: AGENT.toUpperCase(), - tags: [["p", HUMAN.toUpperCase()]], - }), - currentPubkey: HUMAN.toUpperCase(), - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [], - }); - assert.deepEqual(result, [AGENT]); -}); - -test("no-op when anchor author is not a known agent", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ signerPubkey: HUMAN, author: HUMAN }), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [], - }); - assert.deepEqual(result, []); -}); - test("no-op when the agent did not p-tag the current user", () => { const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ tags: [["p", OTHER_AGENT]] }), + anchor: { ...anchor, tags: [] }, currentPubkey: HUMAN, agentPubkeys: new Set([AGENT]), existingMentionPubkeys: [], }); assert.deepEqual(result, []); }); - -test("no-op when the reply already mentions the agent", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor(), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [AGENT.toUpperCase()], - }); - assert.deepEqual(result, []); -}); - -test("prefers signerPubkey over display pubkey/author", () => { - const result = computeAutoContinueAgentMentions({ - anchor: agentAnchor({ signerPubkey: AGENT, pubkey: HUMAN, author: HUMAN }), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [], - }); - assert.deepEqual(result, [AGENT]); -}); - -test("no-op on missing anchor, pubkey, or empty agent set", () => { - const base = { - anchor: agentAnchor(), - currentPubkey: HUMAN, - agentPubkeys: new Set([AGENT]), - existingMentionPubkeys: [], - }; - assert.deepEqual( - computeAutoContinueAgentMentions({ ...base, anchor: null }), - [], - ); - assert.deepEqual( - computeAutoContinueAgentMentions({ ...base, currentPubkey: null }), - [], - ); - assert.deepEqual( - computeAutoContinueAgentMentions({ ...base, agentPubkeys: new Set() }), - [], - ); -}); From e80ff63dbc77e10b7de963fc952213372758a8ce Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 8 Jul 2026 14:22:33 -0700 Subject: [PATCH 4/4] fix(desktop): anchor auto-continue on latest thread message, not root The untagged-thread-reply auto-continue never fired because the anchor (the message inspected for a p-tag mentioning the current user) resolved to threadContext.parentEventId, which defaults to the thread ROOT when no explicit reply target is selected. The agent's p-tagging message is the LAST reply in the thread, not the root, so anchorMentions() always looked at the wrong message and injected no auto-mention. Resolve the anchor from the explicit reply target, else the latest thread message, else the head. Extract resolveAutoContinueAnchorId() as a pure, tested helper. Verified: pnpm typecheck exit 0, pnpm check exit 0, 4/4 unit tests pass. --- .../messages/lib/autoContinueAgent.test.mjs | 27 +++++++++++++++- .../messages/lib/autoContinueAgent.ts | 12 +++++++ .../messages/ui/useAutoContinueThreadSend.ts | 31 ++++++++++++++++--- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs index 83a351c8c98..f3101a761f6 100644 --- a/desktop/src/features/messages/lib/autoContinueAgent.test.mjs +++ b/desktop/src/features/messages/lib/autoContinueAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { computeAutoContinueAgentMentions } from "./autoContinueAgent.ts"; +import { + computeAutoContinueAgentMentions, + resolveAutoContinueAnchorId, +} from "./autoContinueAgent.ts"; const AGENT = "a".repeat(64); const HUMAN = "b".repeat(64); @@ -31,3 +34,25 @@ test("no-op when the agent did not p-tag the current user", () => { }); assert.deepEqual(result, []); }); + +test("anchor resolves to the latest message, not the thread root", () => { + assert.equal( + resolveAutoContinueAnchorId({ + replyTargetId: null, + latestMessageId: "latest", + threadHeadId: "root", + }), + "latest", + ); +}); + +test("anchor prefers an explicit reply target over the latest message", () => { + assert.equal( + resolveAutoContinueAnchorId({ + replyTargetId: "target", + latestMessageId: "latest", + threadHeadId: "root", + }), + "target", + ); +}); diff --git a/desktop/src/features/messages/lib/autoContinueAgent.ts b/desktop/src/features/messages/lib/autoContinueAgent.ts index 40c046bb486..c06a271f9a5 100644 --- a/desktop/src/features/messages/lib/autoContinueAgent.ts +++ b/desktop/src/features/messages/lib/autoContinueAgent.ts @@ -29,6 +29,18 @@ function anchorMentions(anchor: AutoContinueAnchor, pubkey: string): boolean { ); } +export function resolveAutoContinueAnchorId({ + replyTargetId, + latestMessageId, + threadHeadId, +}: { + replyTargetId: string | null | undefined; + latestMessageId: string | null | undefined; + threadHeadId: string | null | undefined; +}): string | null { + return replyTargetId ?? latestMessageId ?? threadHeadId ?? null; +} + export function computeAutoContinueAgentMentions({ anchor, currentPubkey, diff --git a/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts index 871f32d2630..5e44aab7813 100644 --- a/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts +++ b/desktop/src/features/messages/ui/useAutoContinueThreadSend.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import { computeAutoContinueAgentMentions } from "@/features/messages/lib/autoContinueAgent"; +import { + computeAutoContinueAgentMentions, + resolveAutoContinueAnchorId, +} from "@/features/messages/lib/autoContinueAgent"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; @@ -47,12 +50,29 @@ export function useAutoContinueThreadSend({ return index; }, [threadHead, threadReplies]); + const latestMessageId = React.useMemo(() => { + let latest: TimelineMessage | null = threadHead ?? null; + for (const entry of threadReplies) { + if (!latest || entry.message.createdAt >= latest.createdAt) { + latest = entry.message; + } + } + return latest?.id ?? null; + }, [threadHead, threadReplies]); + return React.useCallback( async (content, mentionPubkeys, mediaTags, channelId, threadContext) => { - const anchorId = - threadContext?.parentEventId ?? - replyTargetMessageRef.current?.id ?? - threadHeadId; + // The auto-continue anchor is the message the reply actually continues + // from: an explicitly selected reply target, otherwise the latest message + // in the thread. `threadContext.parentEventId` is not usable here — it + // defaults to the thread ROOT when no reply target is selected, so the + // agent's p-tagging message (the last reply, not the root) would never be + // inspected and the loop would never auto-continue. + const anchorId = resolveAutoContinueAnchorId({ + replyTargetId: replyTargetMessageRef.current?.id, + latestMessageId, + threadHeadId, + }); const anchor = anchorId ? messageById.get(anchorId) : null; const autoMentions = computeAutoContinueAgentMentions({ anchor, @@ -75,6 +95,7 @@ export function useAutoContinueThreadSend({ [ agentPubkeys, currentPubkey, + latestMessageId, messageById, onSend, replyTargetMessageRef,