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 ? ( { + 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); +}); + +// ── 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 new file mode 100644 index 0000000000..e1b16dd862 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/useSentMessageBody.ts @@ -0,0 +1,51 @@ +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; +} + +/** + * 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, + fetchEventById: (eventId: string) => Promise<{ content: string }>, +) { + return { + queryKey: ["sent-message-body", messageLink?.messageId], + 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 10efb79cb4..01a73c7698 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,96 @@ 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 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"', + ); + + 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..10fb74b145 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.includes("$") || content.includes("`")) 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"); });