render mcp apps inline in goose2 (#8877)
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user