From 191fa629b05c8c466fc7e39ed40a996ea42f643c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 20 Jul 2026 19:15:46 -0700 Subject: [PATCH 1/3] fix(desktop): resolve activity feed showing channel UUID instead of message content Stop extracting message content from shell text for agent sends. The classifier now only returns inline --content values; piped stdin, command substitution, and heredoc forms yield null preview, which triggers a fetch of the real event body from the relay via the new useSentMessageBody hook. This eliminates the fallback to extractBuzzCliObjectPreview that surfaced --channel UUIDs as message text. --- .../CompactMessageSummary.tsx | 4 +- .../useSentMessageBody.ts | 20 ++++++ .../ui/agentSessionToolClassifier.test.mjs | 67 +++++++++++++++---- .../agents/ui/agentSessionToolClassifier.ts | 42 +++--------- .../ui/agentSessionToolSummary.test.mjs | 4 +- 5 files changed, 89 insertions(+), 48 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 89aeba920e..424837a068 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -13,6 +13,7 @@ import { useTranscriptBubbleOverflow } from "../activityRenderClasses/useTranscr import { compactSummaryTone } from "./CompactToolSummaryRow"; import type { SentMessageLink } from "./messageLinks"; import { SentMessageContextDialog } from "./SentMessageContextDialog"; +import { useSentMessageBody } from "./useSentMessageBody"; export function CompactMessageSummary({ args, @@ -46,6 +47,7 @@ export function CompactMessageSummary({ timestamp: string; }) { const [detailsOpen, setDetailsOpen] = React.useState(false); + const resolvedContent = useSentMessageBody(messageLink, preview); const variant = useAgentSessionTranscriptVariant(); const { goChannel } = useAppNavigation(); const { openProfilePanel } = useProfilePanel(); @@ -159,7 +161,7 @@ export function CompactMessageSummary({ > {hasBubbleOverflow ? ( getEventById(messageLink?.messageId ?? ""), + enabled: shouldFetch, + staleTime: Number.POSITIVE_INFINITY, + }); + + if (inlineContent) return inlineContent; + return data?.content ?? null; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs index 10efb79cb4..338ade25b0 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import { classifyTool, - extractSimpleEchoPipeContent, parseBuzzCliCommand, tokenizeShellCommand, } from "./agentSessionToolClassifier.ts"; @@ -32,25 +31,69 @@ test("tokenizeShellCommand preserves quoted strings and command separators", () ); }); -test("extractSimpleEchoPipeContent reads the simple echo before a buzz pipe", () => { - const tokens = tokenizeShellCommand( - 'echo -n "Done. Eat my shorts." | buzz messages send --content - --channel agents', +test("parseBuzzCliCommand returns null preview for echo-piped stdin sends", () => { + const descriptor = parseBuzzCliCommand( + 'echo "Permission wired" | buzz messages send --channel agents --content -', ); - assert.equal( - extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")), - "Done. Eat my shorts.", + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.label, "Send Message"); + assert.equal(descriptor?.preview, null); + assert.equal(descriptor?.operation, "messages.send"); +}); + +test("parseBuzzCliCommand returns null preview for printf-piped stdin sends", () => { + const descriptor = parseBuzzCliCommand( + "printf 'hello\\n\\nworld\\n' | buzz messages send --channel a6e0737c-4205-4bcc-9741-2aad800e613f --content -", ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, null); }); -test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => { +test("parseBuzzCliCommand returns null preview for heredoc/cat stdin sends", () => { const descriptor = parseBuzzCliCommand( - 'echo "Permission wired" | buzz messages send --channel agents --content -', + 'buzz messages send --channel some-uuid --content "$(cat /tmp/file)"', ); assert.equal(descriptor?.renderClass, "message"); - assert.equal(descriptor?.label, "Send Message"); - assert.equal(descriptor?.preview, "Permission wired"); - assert.equal(descriptor?.operation, "messages.send"); + assert.equal(descriptor?.preview, null); +}); + +test("parseBuzzCliCommand preserves inline --content for sends", () => { + const descriptor = parseBuzzCliCommand( + 'buzz messages send --channel agents --content "Hello from inline"', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, "Hello from inline"); +}); + +test("parseBuzzCliCommand preserves --content=inline for sends", () => { + const descriptor = parseBuzzCliCommand( + "buzz messages send --channel agents --content=Acknowledged", + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, "Acknowledged"); +}); + +test("parseBuzzCliCommand never surfaces --channel as preview for sends", () => { + const commands = [ + "printf 'msg' | buzz messages send --channel my-uuid --content -", + 'buzz messages send --channel my-uuid --content "$(cat /tmp/f)"', + "buzz messages send --channel my-uuid --content -", + ]; + + for (const cmd of commands) { + const descriptor = parseBuzzCliCommand(cmd); + assert.equal(descriptor?.renderClass, "message"); + assert.notEqual( + descriptor?.preview, + "my-uuid", + `send preview leaked --channel for: ${cmd}`, + ); + } }); test("classifyTool promotes load_skill to skill-read descriptors", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts index 67f6271344..70f09ab2bc 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -363,15 +363,13 @@ export function parseBuzzCliCommand( const group = tokens[range.groupIndex]; const verb = tokens[range.verbIndex] ?? "run"; const operation = `${group}.${verb}`; - const content = - group === "messages" && verb === "send" - ? extractBuzzCliSendMessageContent(tokens, range) - : null; - const preview = content ?? extractBuzzCliObjectPreview(tokens, range); + const isSend = group === "messages" && verb === "send"; + const preview = isSend + ? extractBuzzCliInlineContent(tokens, range) + : extractBuzzCliObjectPreview(tokens, range); const tone = buzzCliTone(group, verb); return { - renderClass: - group === "messages" && verb === "send" ? "message" : "relay-op", + renderClass: isSend ? "message" : "relay-op", label: titleForBuzzCli(group, verb), preview, action: actionForBuzzOperation(operation, preview, tone), @@ -452,14 +450,14 @@ function buzzCliTone(group: string, verb: string): AgentActivityTone { return "write"; } -function extractBuzzCliSendMessageContent( +function extractBuzzCliInlineContent( tokens: string[], range: BuzzCommandRange, ): string | null { const content = getFlagValue(tokens, range.verbIndex + 1, "--content"); - if (!content) return null; - if (content !== "-") return content; - return extractSimpleEchoPipeContent(tokens, range.buzzIndex) ?? null; + if (!content || content === "-") return null; + if (content.startsWith("$(") || content.startsWith("`")) return null; + return content; } function extractBuzzCliObjectPreview( @@ -583,28 +581,6 @@ function getFlagValue(tokens: string[], start: number, flag: string) { return null; } -export function extractSimpleEchoPipeContent( - tokens: string[], - buzzIndex: number, -): string | null { - const pipeIndex = tokens.lastIndexOf("|", buzzIndex); - if (pipeIndex <= 0) return null; - const echoStart = findSegmentStart(tokens, pipeIndex - 1); - const leftSegment = tokens.slice(echoStart, pipeIndex); - if (leftSegment[0] !== "echo") return null; - const contentTokens = leftSegment - .slice(1) - .filter((token) => !token.startsWith("-")); - return contentTokens.length > 0 ? contentTokens.join(" ") : null; -} - -function findSegmentStart(tokens: string[], beforeIndex: number) { - for (let i = beforeIndex; i >= 0; i--) { - if (isCommandSeparator(tokens[i])) return i + 1; - } - return 0; -} - function extractBuzzToolPreview(args: Record): string | null { const content = getToolString(args, ["content", "message", "text", "body"]); if (content) return content; diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index fab1f539ae..ca83ab8b43 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -56,7 +56,7 @@ test("buildCompactToolSummary treats buzz messages send commands as messages", ( assert.equal(summary.presentation, "message"); }); -test("buildCompactToolSummary extracts simple piped buzz message content", () => { +test("buildCompactToolSummary returns null preview for piped stdin sends", () => { const summary = buildCompactToolSummary( makeTool({ toolName: "shell", @@ -68,7 +68,7 @@ test("buildCompactToolSummary extracts simple piped buzz message content", () => ); assert.equal(summary.label, "Send Message"); - assert.equal(summary.preview, "hello from stdin"); + assert.equal(summary.preview, null); assert.equal(summary.presentation, "message"); }); From 267e5bb993c6bb23a026ab4f6f08c76410684740 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 20 Jul 2026 19:19:05 -0700 Subject: [PATCH 2/3] test(desktop): add hook resolution tests for useSentMessageBody Extract shouldFetchSentMessage and resolveSentMessageBody as pure testable helpers from the hook. Tests cover the priority chain (inline > fetched > null) and fetch-gating conditions. --- .../useSentMessageBody.test.mjs | 37 +++++++++++++++++++ .../useSentMessageBody.ts | 22 +++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs new file mode 100644 index 0000000000..da44ef2ca6 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + resolveSentMessageBody, + shouldFetchSentMessage, +} from "./useSentMessageBody.ts"; + +const link = { channelId: "ch-1", messageId: "ev-1" }; + +test("shouldFetchSentMessage returns true when messageLink present and no inline content", () => { + assert.equal(shouldFetchSentMessage(link, null), true); +}); + +test("shouldFetchSentMessage returns false when inline content is present", () => { + assert.equal(shouldFetchSentMessage(link, "hello"), false); +}); + +test("shouldFetchSentMessage returns false when messageLink is null", () => { + assert.equal(shouldFetchSentMessage(null, null), false); +}); + +test("resolveSentMessageBody returns inline content over fetched content", () => { + assert.equal( + resolveSentMessageBody("inline text", "fetched text"), + "inline text", + ); +}); + +test("resolveSentMessageBody returns fetched content when no inline content", () => { + assert.equal(resolveSentMessageBody(null, "fetched text"), "fetched text"); +}); + +test("resolveSentMessageBody returns null when both inline and fetched are absent", () => { + assert.equal(resolveSentMessageBody(null, undefined), null); + assert.equal(resolveSentMessageBody(null, null), null); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts index cee64193c7..6b0bbd8e5f 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts @@ -3,18 +3,32 @@ import { useQuery } from "@tanstack/react-query"; import { getEventById } from "@/shared/api/tauri"; import type { SentMessageLink } from "./messageLinks"; +export function shouldFetchSentMessage( + messageLink: SentMessageLink | null, + inlineContent: string | null, +): boolean { + return messageLink !== null && inlineContent === null; +} + +export function resolveSentMessageBody( + inlineContent: string | null, + fetchedContent: string | undefined | null, +): string | null { + if (inlineContent) return inlineContent; + return fetchedContent ?? null; +} + export function useSentMessageBody( messageLink: SentMessageLink | null, inlineContent: string | null, ): string | null { - const shouldFetch = messageLink !== null && inlineContent === null; + const enabled = shouldFetchSentMessage(messageLink, inlineContent); const { data } = useQuery({ queryKey: ["sent-message-body", messageLink?.messageId], queryFn: () => getEventById(messageLink?.messageId ?? ""), - enabled: shouldFetch, + enabled, staleTime: Number.POSITIVE_INFINITY, }); - if (inlineContent) return inlineContent; - return data?.content ?? null; + return resolveSentMessageBody(inlineContent, data?.content); } From a27a6e5969c790fbc1750a586dbb6b9e41f4ccb9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 20 Jul 2026 20:07:28 -0700 Subject: [PATCH 3/3] fix(desktop): reject dynamic --content values and cover fetch/render paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the inline-content guard to reject any --content value containing a $ or backtick anywhere (not just leading $( or backtick), so shell variable expansion and embedded command substitution no longer leak as literal message text — those forms now fetch the authoritative event body instead. Add integration coverage for useSentMessageBody driving the real useQuery/QueryObserver wiring (fetch-by-messageId, disabled-when-inline, fetch-miss) plus render-level assertions for the fetched-content and placeholder paths, closing the gap flagged in review pass 1/3. --- .../useSentMessageBody.test.mjs | 137 ++++++++++++++++++ .../useSentMessageBody.ts | 31 +++- .../ui/agentSessionToolClassifier.test.mjs | 27 ++++ .../agents/ui/agentSessionToolClassifier.ts | 2 +- 4 files changed, 189 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs index da44ef2ca6..e880fff42b 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.test.mjs @@ -1,9 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + QueryClient, + QueryClientProvider, + QueryObserver, +} from "@tanstack/react-query"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + import { resolveSentMessageBody, + sentMessageBodyQueryOptions, shouldFetchSentMessage, + useSentMessageBody, } from "./useSentMessageBody.ts"; const link = { channelId: "ch-1", messageId: "ev-1" }; @@ -35,3 +45,130 @@ test("resolveSentMessageBody returns null when both inline and fetched are absen assert.equal(resolveSentMessageBody(null, undefined), null); assert.equal(resolveSentMessageBody(null, null), null); }); + +// ── Integration: the real useQuery wiring, not just the pure helpers ────────── +// +// `sentMessageBodyQueryOptions` is the exact options object `useSentMessageBody` +// hands to `useQuery`. Driving it through query-core's `QueryObserver` (what +// `useQuery` constructs under the hood) exercises the production fetch/cache +// wiring without a DOM — these tests fail if the hook stops calling the +// injected fetcher, stops keying by `messageId`, or stops gating on +// `enabled`. + +test("useSentMessageBody wiring fetches by messageId and resolves fetched content", async () => { + const queryClient = new QueryClient(); + let calledWith = null; + const fetchEventById = async (eventId) => { + calledWith = eventId; + return { content: "hello from the event" }; + }; + const options = sentMessageBodyQueryOptions(link, null, fetchEventById); + const observer = new QueryObserver(queryClient, options); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + unsubscribe(); + + assert.equal(calledWith, "ev-1"); + assert.equal( + resolveSentMessageBody(null, observer.getCurrentResult().data?.content), + "hello from the event", + ); +}); + +test("useSentMessageBody wiring never invokes the fetcher when inline content is present", async () => { + const queryClient = new QueryClient(); + let called = false; + const fetchEventById = async () => { + called = true; + return { content: "unused" }; + }; + const options = sentMessageBodyQueryOptions( + link, + "inline text", + fetchEventById, + ); + const observer = new QueryObserver(queryClient, options); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + unsubscribe(); + + assert.equal(called, false); +}); + +test("useSentMessageBody wiring leaves data undefined on a rejected fetch (fetch-miss path)", async () => { + const queryClient = new QueryClient(); + const fetchEventById = async () => { + throw new Error("relay miss"); + }; + const options = sentMessageBodyQueryOptions(link, null, fetchEventById); + const observer = new QueryObserver(queryClient, { ...options, retry: false }); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + unsubscribe(); + + assert.equal(observer.getCurrentResult().data, undefined); +}); + +// ── Render-level coverage ────────────────────────────────────────────────── +// +// Renders the hook through a real QueryClientProvider. Cache is pre-seeded +// via `setQueryData` (equivalent to a settled fetch) for the render +// assertion, since `renderToStaticMarkup` never commits and so never runs +// the query's mount effect — the wiring tests above already prove the +// fetcher is called through the real `useQuery` path. + +function SentMessageBodyProbe({ messageLink, inlineContent, fetchEventById }) { + const content = useSentMessageBody( + messageLink, + inlineContent, + fetchEventById, + ); + return React.createElement( + "span", + null, + content ?? "Message content unavailable.", + ); +} + +function renderProbe(queryClient, props) { + return renderToStaticMarkup( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(SentMessageBodyProbe, props), + ), + ); +} + +test("CompactMessageSummary renders fetched event content when a cached fetch has resolved", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["sent-message-body", link.messageId], { + content: "hello from the event", + }); + const fetchEventById = async () => { + throw new Error("should not be called — cache hit"); + }; + + const html = renderProbe(queryClient, { + messageLink: link, + inlineContent: null, + fetchEventById, + }); + + assert.match(html, /hello from the event/); +}); + +test("CompactMessageSummary renders the placeholder, never a raw flag value, on a fetch miss", () => { + const queryClient = new QueryClient(); + const fetchEventById = async () => { + throw new Error("relay miss"); + }; + + const html = renderProbe(queryClient, { + messageLink: link, + inlineContent: null, + fetchEventById, + }); + + assert.match(html, /Message content unavailable\./); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts index 6b0bbd8e5f..e1b16dd862 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts @@ -18,17 +18,34 @@ export function resolveSentMessageBody( return fetchedContent ?? null; } -export function useSentMessageBody( +/** + * Builds the exact options object `useSentMessageBody` passes to `useQuery`. + * Extracted so tests can drive the real fetch through query-core's + * `QueryObserver` (what `useQuery` constructs internally) without a DOM. + */ +export function sentMessageBodyQueryOptions( messageLink: SentMessageLink | null, inlineContent: string | null, -): string | null { - const enabled = shouldFetchSentMessage(messageLink, inlineContent); - const { data } = useQuery({ + fetchEventById: (eventId: string) => Promise<{ content: string }>, +) { + return { queryKey: ["sent-message-body", messageLink?.messageId], - queryFn: () => getEventById(messageLink?.messageId ?? ""), - enabled, + queryFn: () => fetchEventById(messageLink?.messageId ?? ""), + enabled: shouldFetchSentMessage(messageLink, inlineContent), staleTime: Number.POSITIVE_INFINITY, - }); + }; +} + +export function useSentMessageBody( + messageLink: SentMessageLink | null, + inlineContent: string | null, + fetchEventById: ( + eventId: string, + ) => Promise<{ content: string }> = getEventById, +): string | null { + const { data } = useQuery( + sentMessageBodyQueryOptions(messageLink, inlineContent, fetchEventById), + ); return resolveSentMessageBody(inlineContent, data?.content); } diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs index 338ade25b0..01a73c7698 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -60,6 +60,33 @@ test("parseBuzzCliCommand returns null preview for heredoc/cat stdin sends", () assert.equal(descriptor?.preview, null); }); +test("parseBuzzCliCommand returns null preview for --content with embedded command substitution", () => { + const descriptor = parseBuzzCliCommand( + 'buzz messages send --channel some-uuid --content "prefix $(cat /tmp/f)"', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, null); +}); + +test("parseBuzzCliCommand returns null preview for --content with a bare variable", () => { + const descriptor = parseBuzzCliCommand( + 'buzz messages send --channel some-uuid --content "$MESSAGE"', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, null); +}); + +test("parseBuzzCliCommand returns null preview for --content with a prefixed variable", () => { + const descriptor = parseBuzzCliCommand( + 'buzz messages send --channel some-uuid --content "prefix $MESSAGE"', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.preview, null); +}); + test("parseBuzzCliCommand preserves inline --content for sends", () => { const descriptor = parseBuzzCliCommand( 'buzz messages send --channel agents --content "Hello from inline"', diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts index 70f09ab2bc..10fb74b145 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -456,7 +456,7 @@ function extractBuzzCliInlineContent( ): string | null { const content = getFlagValue(tokens, range.verbIndex + 1, "--content"); if (!content || content === "-") return null; - if (content.startsWith("$(") || content.startsWith("`")) return null; + if (content.includes("$") || content.includes("`")) return null; return content; }