fix(desktop): resolve activity feed showing channel UUID instead of message content (#2201)

This commit is contained in:
Will Pfleger
2026-07-21 00:09:47 -04:00
committed by GitHub
parent 7d7992067b
commit c5d00a3589
6 changed files with 322 additions and 49 deletions
@@ -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({
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
content={preview || "Message content unavailable."}
content={resolvedContent || "Message content unavailable."}
/>
{hasBubbleOverflow ? (
<span
@@ -0,0 +1,174 @@
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" };
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);
});
// ── 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\./);
});
@@ -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);
}
@@ -3,7 +3,6 @@ import test from "node:test";
import {
classifyTool,
extractSimpleEchoPipeContent,
parseBuzzCliCommand,
tokenizeShellCommand,
} from "./agentSessionToolClassifier.ts";
@@ -32,27 +31,98 @@ 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',
);
assert.equal(
extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")),
"Done. Eat my shorts.",
);
});
test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => {
test("parseBuzzCliCommand returns null preview for echo-piped stdin sends", () => {
const descriptor = parseBuzzCliCommand(
'echo "Permission wired" | buzz messages send --channel agents --content -',
);
assert.equal(descriptor?.renderClass, "message");
assert.equal(descriptor?.label, "Send Message");
assert.equal(descriptor?.preview, "Permission wired");
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 returns null preview for heredoc/cat stdin sends", () => {
const descriptor = parseBuzzCliCommand(
'buzz messages send --channel some-uuid --content "$(cat /tmp/file)"',
);
assert.equal(descriptor?.renderClass, "message");
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", () => {
const descriptor = classifyTool({
title: "load_skill",
@@ -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, unknown>): string | null {
const content = getToolString(args, ["content", "message", "text", "body"]);
if (content) return content;
@@ -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");
});