render mcp apps inline in goose2 (#8877)

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
Andrew Harvard
2026-05-04 08:25:38 -04:00
committed by GitHub
parent 4f7a2e8a52
commit b11c9a3d98
50 changed files with 3403 additions and 78 deletions
@@ -151,6 +151,60 @@ describe("acpNotificationHandler", () => {
).toBe("assistant-1");
});
it("preserves structured tool output when ACP provides rawOutput", async () => {
registerSession(
"local-session",
"goose-session",
"goose",
"/Users/aharvard/.goose/artifacts",
);
setActiveMessageId("goose-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "mcp_app_bench__inspect_host_info",
},
} as never);
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
content: [
{
type: "content",
content: {
type: "text",
text: "Opened the Host Info inspector.",
},
},
],
rawOutput: {
inspector: "host-info",
supported: true,
},
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
expect(message.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
result: "Opened the Host Info inspector.",
structuredContent: {
inspector: "host-info",
supported: true,
},
isError: false,
});
});
it("replay keeps tool and MCP app content on an assistant message when tool events arrive before text", async () => {
const replaySessionId = "replay-goose-session";
useChatStore.setState({
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { extractToolStructuredContent } from "../acpToolCallContent";
describe("extractToolStructuredContent", () => {
it.each([
[{ restaurants: [{ name: "Coffee Shop" }] }, "object"],
["complete", "string"],
[42, "number"],
[false, "boolean"],
[null, "null"],
])("preserves %s rawOutput values", (rawOutput, _label) => {
expect(extractToolStructuredContent({ rawOutput })).toEqual(rawOutput);
});
it("returns undefined when rawOutput is absent", () => {
expect(extractToolStructuredContent({})).toBeUndefined();
});
});
@@ -17,6 +17,7 @@ import type { AcpNotificationHandler } from "./acpConnection";
import { handleReplayUserMessageChunk } from "./acpSkillReplayChips";
import {
attachMcpAppPayload,
extractToolStructuredContent,
extractToolResultText,
findReplayMessageWithToolCall,
} from "./acpToolCallContent";
@@ -146,6 +147,12 @@ export function clearReplayPerf(sessionId: string): void {
replayPerf.delete(sessionId);
}
function getChunkMessageId(update: SessionUpdate): string | null {
return "messageId" in update && typeof update.messageId === "string"
? update.messageId
: null;
}
function handleReplay(
sessionId: string,
gooseSessionId: string,
@@ -256,6 +263,7 @@ function handleReplay(
id: update.toolCallId,
name: (tc as ToolRequestContent)?.name ?? "",
result: resultText,
structuredContent: extractToolStructuredContent(update),
isError: update.status === "failed",
});
if (update.status === "completed") {
@@ -300,7 +308,7 @@ function handleLive(
const messageId = ensureLiveAssistantMessage(
sessionId,
gooseSessionId,
update.messageId,
getChunkMessageId(update) ?? undefined,
);
if (update.content.type === "text" && "text" in update.content) {
@@ -375,6 +383,7 @@ function handleLive(
id: update.toolCallId,
name: toolRequest?.name ?? "",
result: resultText,
structuredContent: extractToolStructuredContent(update),
isError: update.status === "failed",
};
store.setStreamingMessageId(sessionId, messageId);
@@ -386,6 +395,9 @@ function handleLive(
toolRequest?.name ?? update.title ?? "",
update,
false,
{
gooseSessionId,
},
);
}
}
@@ -49,6 +49,16 @@ export function extractToolResultText(update: {
return "";
}
export function extractToolStructuredContent(update: {
rawOutput?: unknown;
}): unknown | undefined {
if (Object.hasOwn(update, "rawOutput")) {
return update.rawOutput;
}
return undefined;
}
export function attachMcpAppPayload(
sessionId: string,
toolCallId: string,
@@ -0,0 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
export interface GooseServeHostInfo {
// Rename to baseUrl when goose serve supports a secure local origin.
httpBaseUrl: string;
secretKey: string;
}
export async function getGooseServeHostInfo(): Promise<GooseServeHostInfo> {
return invoke<GooseServeHostInfo>("get_goose_serve_host_info");
}
@@ -124,7 +124,9 @@
"message": {
"copied": "Copied",
"defaultImageAlt": "Attached",
"mcpAppUnderConstruction": "🚧 MCP App Rendering is under construction",
"mcpApp": "MCP App",
"mcpAppLoading": "Loading MCP App…",
"mcpAppRenderError": "Unable to render MCP App inline.",
"redactedThinking": "(thinking redacted)"
},
"persona": {
@@ -189,11 +191,15 @@
"voiceInputAutoSubmitHint": "Say \"submit\" to send"
},
"tools": {
"content": "Content",
"fileNotFound": "File not found: {{path}}",
"moreOutputs": "More outputs ({{count}})",
"openFile": "Open file",
"openFolder": "Open folder",
"openPath": "Open path",
"pathOutsideRoots": "Path is outside allowed project/artifacts roots."
"pathOutsideRoots": "Path is outside allowed project/artifacts roots.",
"structuredContent": "Structured content",
"structuredOutput": "Structured output",
"structuredOutputLines": "{{count}} lines"
}
}
@@ -124,7 +124,9 @@
"message": {
"copied": "Copiado",
"defaultImageAlt": "Adjunto",
"mcpAppUnderConstruction": "🚧 La renderización de MCP App está en construcción",
"mcpApp": "MCP App",
"mcpAppLoading": "Cargando MCP App…",
"mcpAppRenderError": "No se pudo renderizar MCP App en línea.",
"redactedThinking": "(pensamiento redactado)"
},
"persona": {
@@ -189,11 +191,15 @@
"voiceInputAutoSubmitHint": "Di \"enviar\" para enviar"
},
"tools": {
"content": "Contenido",
"fileNotFound": "Archivo no encontrado: {{path}}",
"moreOutputs": "Más salidas ({{count}})",
"openFile": "Abrir archivo",
"openFolder": "Abrir carpeta",
"openPath": "Abrir ruta",
"pathOutsideRoots": "La ruta está fuera de las raíces permitidas de proyecto/artefactos."
"pathOutsideRoots": "La ruta está fuera de las raíces permitidas de proyecto/artefactos.",
"structuredContent": "Contenido estructurado",
"structuredOutput": "Salida estructurada",
"structuredOutputLines": "{{count}} líneas"
}
}
+1
View File
@@ -92,6 +92,7 @@ export interface ToolResponseContent {
id: string;
name: string;
result: string;
structuredContent?: unknown;
isError: boolean;
annotations?: ContentAnnotations;
}
@@ -57,17 +57,26 @@ const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
const TokenSpan = ({
token,
transparentBackground,
}: {
token: ThemedToken;
transparentBackground: boolean;
}) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
className={cn(
"dark:!text-[var(--shiki-dark)]",
!transparentBackground && "dark:!bg-[var(--shiki-dark-bg)]",
)}
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
backgroundColor: transparentBackground ? undefined : token.bgColor,
} as CSSProperties
}
>
@@ -93,15 +102,21 @@ const LINE_NUMBER_CLASSES = cn(
const LineSpan = ({
keyedLine,
showLineNumbers,
transparentBackground,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
transparentBackground: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
<TokenSpan
key={key}
token={token}
transparentBackground={transparentBackground}
/>
))}
</span>
);
@@ -111,6 +126,8 @@ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
viewportClassName?: string;
transparentBackground?: boolean;
};
interface TokenizedCode {
@@ -249,17 +266,19 @@ const CodeBlockBody = memo(
tokenized,
showLineNumbers,
className,
transparentBackground,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
transparentBackground: boolean;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
backgroundColor: transparentBackground ? "transparent" : tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg],
[tokenized.bg, tokenized.fg, transparentBackground],
);
const keyedLines = useMemo(
@@ -270,7 +289,8 @@ const CodeBlockBody = memo(
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
"dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
!transparentBackground && "dark:!bg-[var(--shiki-dark-bg)]",
className,
)}
style={preStyle}
@@ -287,6 +307,7 @@ const CodeBlockBody = memo(
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
transparentBackground={transparentBackground}
/>
))}
</code>
@@ -296,7 +317,8 @@ const CodeBlockBody = memo(
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className,
prevProps.className === nextProps.className &&
prevProps.transparentBackground === nextProps.transparentBackground,
);
CodeBlockBody.displayName = "CodeBlockBody";
@@ -309,7 +331,7 @@ export const CodeBlockContainer = ({
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
"group relative w-full min-w-0 max-w-full overflow-hidden rounded-md border bg-background text-foreground",
className,
)}
data-language={language}
@@ -375,10 +397,14 @@ export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
viewportClassName,
transparentBackground = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
viewportClassName?: string;
transparentBackground?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
@@ -419,8 +445,17 @@ export const CodeBlockContent = ({
const tokenized = highlightedTokens ?? rawTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
<div
className={cn(
"relative min-w-0 max-w-full overflow-auto",
viewportClassName,
)}
>
<CodeBlockBody
showLineNumbers={showLineNumbers}
tokenized={tokenized}
transparentBackground={transparentBackground}
/>
</div>
);
};
@@ -429,6 +464,8 @@ export const CodeBlock = ({
code,
language,
showLineNumbers = false,
viewportClassName,
transparentBackground = false,
className,
children,
...props
@@ -443,6 +480,8 @@ export const CodeBlock = ({
code={code}
language={language}
showLineNumbers={showLineNumbers}
viewportClassName={viewportClassName}
transparentBackground={transparentBackground}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
@@ -14,12 +14,14 @@ import {
interface LinkSafetyModalProps {
isOpen: boolean;
onClose: () => void;
onOpenLink?: (url: string) => Promise<void>;
url: string;
}
export function LinkSafetyModal({
isOpen,
onClose,
onOpenLink,
url,
}: LinkSafetyModalProps) {
const { t } = useTranslation("common");
@@ -39,12 +41,12 @@ export function LinkSafetyModal({
const handleOpen = useCallback(async () => {
try {
await openUrl(url);
await (onOpenLink ?? openUrl)(url);
} catch (e: unknown) {
console.error("[linkSafety] openUrl failed:", e);
}
onClose();
}, [url, onClose]);
}, [url, onClose, onOpenLink]);
const handleCopy = useCallback(() => {
if (isCopied) return;
+52 -10
View File
@@ -21,7 +21,10 @@ import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible className={cn("group not-prose w-full", className)} {...props} />
<Collapsible
className={cn("group not-prose w-full min-w-0 max-w-full", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
@@ -106,7 +109,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 min-w-0 max-w-full space-y-4 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
@@ -131,39 +134,78 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
label?: string;
contentClassName?: string;
plainText?: boolean;
};
export const ToolOutput = ({
className,
output,
errorText,
label,
contentClassName,
plainText = false,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
if (output === undefined && errorText === undefined) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
let isCodeBlockOutput = false;
const isReactOutput = isValidElement(output);
if (typeof output === "object" && !isValidElement(output)) {
if (output !== null && typeof output === "object" && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
<CodeBlock
code={JSON.stringify(output, null, 2)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
);
} else if (typeof output === "string" && plainText) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{output}
</pre>
);
} else if (output !== undefined && !plainText && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock
code={JSON.stringify(output) ?? String(output)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
);
} else if (output !== undefined && output !== null && !isReactOutput) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{String(output)}
</pre>
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<div
className={cn("w-full min-w-0 max-w-full space-y-2", className)}
{...props}
>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
{label ?? (errorText ? "Error" : "Result")}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
"w-full rounded-md text-xs [&_table]:w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground",
!isCodeBlockOutput && "overflow-x-auto",
contentClassName,
)}
>
{errorText && <div>{errorText}</div>}