mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 07:16:03 +00:00
fix(ui): preserve MCP App previews across reloads (#105728)
* fix(mcp): harden app preview lifecycle * fix(ui): defer MCP app initialization payload * test(mcp): align current policy expectations * fix(ui): preserve MCP app previews * fix(mcp): reject disabled app restoration * fix(mcp): preserve reconstructed app state * fix(mcp): bind reconstructable app data * fix(ui): deduplicate MCP app preview copies * style(ui): format MCP app preview test * fix(mcp): satisfy metadata access lint * style(mcp): format metadata access
This commit is contained in:
@@ -872,9 +872,9 @@ Behavior and security boundaries:
|
||||
- OpenClaw advertises the `io.modelcontextprotocol/ui` extension only when Apps are enabled.
|
||||
- Only `ui://` resources with the exact `text/html;profile=mcp-app` MIME type render.
|
||||
- UI resources are capped at 2 MiB, placed behind a double-iframe proxy on a dedicated outer origin, loaded into an opaque inner App origin, and constrained by CSP derived from the resource metadata.
|
||||
- App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server.
|
||||
- App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server that also pass the effective OpenClaw tool policy for the run that created the view.
|
||||
- Origin-bound App permissions such as camera, microphone, and geolocation are not granted while inner App documents use opaque origins for cross-App isolation.
|
||||
- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease. They are not written to disk or copied into transcript preview metadata, and an expired view does not restart its MCP runtime.
|
||||
- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease and are not written to disk or copied into transcript preview metadata. The transcript stores only a bounded server/tool/resource descriptor tied to the original tool-call ID. After a Gateway restart, the Control UI can verify that descriptor against the authenticated session transcript and refetch the `ui://` resource; reconstructed views are read-only until a fresh run establishes current tool permissions.
|
||||
- `openclaw security audit` warns while the bridge is enabled. Disable it with `openclaw config set mcp.apps.enabled false --strict-json` when it is not needed.
|
||||
|
||||
## Current limits
|
||||
|
||||
@@ -414,7 +414,7 @@ export async function materializeBundleMcpToolsForRun(params: {
|
||||
const tools = buildBundleMcpToolsFromCatalog({
|
||||
catalog,
|
||||
reservedToolNames,
|
||||
createExecute: (tool) => async (_toolCallId: string, input: unknown) => {
|
||||
createExecute: (tool) => async (toolCallId: string, input: unknown) => {
|
||||
params.runtime.markUsed();
|
||||
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
|
||||
const agentResult = toAgentToolResult({
|
||||
@@ -431,13 +431,18 @@ export async function materializeBundleMcpToolsForRun(params: {
|
||||
serverName: tool.serverName,
|
||||
toolName: tool.toolName,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
toolCallId,
|
||||
toolInput: input,
|
||||
toolResult: result,
|
||||
...(allowedAppToolNames ? { allowedAppToolNames } : {}),
|
||||
});
|
||||
if (view) {
|
||||
(agentResult.details as Record<string, unknown>).mcpAppPreview =
|
||||
buildMcpAppCanvasPayload(view);
|
||||
(agentResult.details as Record<string, unknown>).mcpAppPreview = buildMcpAppCanvasPayload(
|
||||
{
|
||||
...view,
|
||||
...(result["_meta"] !== undefined ? { resultMetaState: "unavailable" as const } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return agentResult;
|
||||
|
||||
@@ -19,11 +19,26 @@ const mcpAppMocks = vi.hoisted(() => ({ fetchMcpAppView: vi.fn() }));
|
||||
|
||||
vi.mock("./mcp-ui-resource.js", () => ({
|
||||
fetchMcpAppView: mcpAppMocks.fetchMcpAppView,
|
||||
buildMcpAppCanvasPayload: (view: { viewId: string; title: string }) => ({
|
||||
buildMcpAppCanvasPayload: (view: {
|
||||
viewId: string;
|
||||
title: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
resultMetaState?: "unavailable";
|
||||
}) => ({
|
||||
kind: "canvas",
|
||||
view: { id: view.viewId, title: view.title },
|
||||
presentation: { target: "assistant_message", sandbox: "scripts" },
|
||||
mcpApp: { viewId: view.viewId },
|
||||
mcpApp: {
|
||||
viewId: view.viewId,
|
||||
serverName: view.serverName,
|
||||
toolName: view.toolName,
|
||||
uiResourceUri: view.uiResourceUri,
|
||||
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
|
||||
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -152,6 +167,10 @@ describe("createBundleMcpToolRuntime", () => {
|
||||
mcpAppMocks.fetchMcpAppView.mockResolvedValue({
|
||||
viewId: "cv_app",
|
||||
title: "Demo UI",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
const tool: McpCatalogTool = {
|
||||
serverName: "demo",
|
||||
@@ -166,6 +185,7 @@ describe("createBundleMcpToolRuntime", () => {
|
||||
serverName: "demo",
|
||||
result: {
|
||||
content: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
_meta: { "ui/state": { selected: true } },
|
||||
},
|
||||
});
|
||||
sessionRuntime.mcpAppsEnabled = true;
|
||||
@@ -178,10 +198,22 @@ describe("createBundleMcpToolRuntime", () => {
|
||||
).execute("call-1", {}, undefined, undefined);
|
||||
expect(result.content).toEqual([{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }]);
|
||||
expect(result.details).toMatchObject({
|
||||
mcpAppPreview: { mcpApp: { viewId: "cv_app" } },
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "cv_app",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mcpAppMocks.fetchMcpAppView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowedAppToolNames: new Set(["show"]) }),
|
||||
expect.objectContaining({
|
||||
toolCallId: "call-1",
|
||||
allowedAppToolNames: new Set(["show"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,7 +113,12 @@ export function buildMcpAppSandboxProxyHtml(): string {
|
||||
<script>
|
||||
(() => {
|
||||
if (window.self === window.top) throw new Error("invalid MCP App sandbox host");
|
||||
let hostOrigin = null;
|
||||
let hostOrigin;
|
||||
try {
|
||||
const referrer = new URL(document.referrer);
|
||||
if (referrer.protocol !== "http:" && referrer.protocol !== "https:") throw new Error();
|
||||
hostOrigin = referrer.origin;
|
||||
} catch { throw new Error("invalid MCP App sandbox parent"); }
|
||||
try { void window.top.document; throw new Error("MCP App sandbox isolation failed"); } catch (error) {
|
||||
if (error instanceof Error && error.message === "MCP App sandbox isolation failed") throw error;
|
||||
}
|
||||
@@ -122,7 +127,6 @@ export function buildMcpAppSandboxProxyHtml(): string {
|
||||
document.body.appendChild(inner);
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
if (hostOrigin === null) hostOrigin = event.origin;
|
||||
if (event.origin !== hostOrigin) return;
|
||||
if (event.data?.method === "ui/notifications/sandbox-resource-ready") {
|
||||
const params = event.data.params ?? {};
|
||||
@@ -135,12 +139,12 @@ export function buildMcpAppSandboxProxyHtml(): string {
|
||||
inner.contentWindow?.postMessage(event.data, "*");
|
||||
return;
|
||||
}
|
||||
if (event.source === inner.contentWindow && hostOrigin !== null) {
|
||||
if (event.source === inner.contentWindow) {
|
||||
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
|
||||
window.parent.postMessage(event.data, hostOrigin);
|
||||
}
|
||||
});
|
||||
window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} }, "*");
|
||||
window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} }, hostOrigin);
|
||||
})();
|
||||
</script>
|
||||
</body>`;
|
||||
@@ -164,6 +168,7 @@ export function buildMcpAppContentSecurityPolicy(csp?: McpAppCsp): string {
|
||||
`base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`,
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors http: https:",
|
||||
];
|
||||
if (csp) {
|
||||
directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim());
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("MCP App UI resources", () => {
|
||||
expect(policy).toContain("base-uri 'self'");
|
||||
expect(policy).not.toContain("javascript:alert");
|
||||
expect(view?.html.startsWith("<!doctype html>")).toBe(true);
|
||||
expect(policy).not.toContain("frame-ancestors");
|
||||
expect(policy).toContain("frame-ancestors http: https:");
|
||||
const sandboxPath = buildMcpAppSandboxPath(view?.csp);
|
||||
const encodedCsp = new URL(sandboxPath, "https://gateway.example").searchParams.get("csp");
|
||||
expect(decodeMcpAppSandboxCsp(encodedCsp)).toStrictEqual(view?.csp);
|
||||
@@ -204,6 +204,8 @@ describe("MCP App UI resources", () => {
|
||||
expect(proxyHtml).not.toContain("doc.write");
|
||||
expect(proxyHtml).not.toContain("params.sandbox");
|
||||
expect(proxyHtml).not.toContain("params.permissions");
|
||||
expect(proxyHtml).toContain("document.referrer");
|
||||
expect(proxyHtml).not.toContain("hostOrigin === null");
|
||||
expect(proxyHtml).toContain('startsWith("ui/notifications/sandbox-")');
|
||||
});
|
||||
|
||||
@@ -310,4 +312,39 @@ describe("MCP App UI resources", () => {
|
||||
expect(getMcpAppViewLease(viewIds[0] ?? "", sessionRuntime)).toBeDefined();
|
||||
expect(getMcpAppViewLease(viewIds[31] ?? "", sessionRuntime)).toBeDefined();
|
||||
});
|
||||
|
||||
it("replaces a reconstructed view id without leaking the previous runtime lease", async () => {
|
||||
const releases = [vi.fn(), vi.fn()];
|
||||
const sessionRuntime = runtime(async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: "ui://demo/app",
|
||||
mimeType: MCP_APP_RESOURCE_MIME_TYPE,
|
||||
text: "<html>demo</html>",
|
||||
},
|
||||
],
|
||||
}));
|
||||
sessionRuntime.acquireLease = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(releases[0])
|
||||
.mockReturnValueOnce(releases[1]);
|
||||
|
||||
for (const version of [1, 2]) {
|
||||
await fetchMcpAppView({
|
||||
runtime: sessionRuntime,
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
viewId: "mcp-app-restored",
|
||||
toolInput: { version },
|
||||
toolResult: { content: [] },
|
||||
});
|
||||
}
|
||||
|
||||
expect(releases[0]).toHaveBeenCalledOnce();
|
||||
expect(releases[1]).not.toHaveBeenCalled();
|
||||
expect(getMcpAppViewLease("mcp-app-restored", sessionRuntime)?.toolInput).toEqual({
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,27 @@ function measureViewBytes(html: string, toolInput: unknown, toolResult: CallTool
|
||||
return byteSize;
|
||||
}
|
||||
|
||||
function assertBoundedViewDescriptor(value: {
|
||||
viewId?: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
}): void {
|
||||
if (
|
||||
(value.viewId && (value.viewId.length > 128 || !value.viewId.startsWith("mcp-app-"))) ||
|
||||
!value.serverName ||
|
||||
value.serverName.length > 256 ||
|
||||
!value.toolName ||
|
||||
value.toolName.length > 256 ||
|
||||
!value.uiResourceUri.startsWith("ui://") ||
|
||||
value.uiResourceUri.length > 2_048 ||
|
||||
(value.toolCallId !== undefined && value.toolCallId.length > 512)
|
||||
) {
|
||||
throw new Error("MCP App preview descriptor exceeds safe limits");
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -176,12 +197,25 @@ export async function fetchMcpAppView(params: {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
toolInput: unknown;
|
||||
toolResult: CallToolResult;
|
||||
allowedAppToolNames?: ReadonlySet<string>;
|
||||
}): Promise<{ viewId: string; title: string } | undefined> {
|
||||
viewId?: string;
|
||||
}): Promise<
|
||||
| {
|
||||
viewId: string;
|
||||
title: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
let releaseRuntimeLease: (() => void) | undefined;
|
||||
try {
|
||||
assertBoundedViewDescriptor(params);
|
||||
if (!params.runtime.readResource || !params.uiResourceUri.startsWith("ui://")) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -209,8 +243,9 @@ export async function fetchMcpAppView(params: {
|
||||
const csp = normalizeMcpAppCsp(uiMeta?.csp);
|
||||
const permissions = normalizePermissions(uiMeta?.permissions);
|
||||
const title = `${params.toolName} UI`;
|
||||
const viewId = `mcp-app-${randomUUID()}`;
|
||||
const viewId = params.viewId ?? `mcp-app-${randomUUID()}`;
|
||||
releaseRuntimeLease = params.runtime.acquireLease?.();
|
||||
deleteView(viewId);
|
||||
pruneViewStore(byteSize, { reserveEntry: true });
|
||||
const view: McpAppViewLease = {
|
||||
viewId,
|
||||
@@ -241,7 +276,14 @@ export async function fetchMcpAppView(params: {
|
||||
}, MCP_APP_VIEW_TTL_MS);
|
||||
view.expiryTimer.unref?.();
|
||||
getViewStore().set(viewId, view);
|
||||
return { viewId, title };
|
||||
return {
|
||||
viewId,
|
||||
title,
|
||||
serverName: params.serverName,
|
||||
toolName: params.toolName,
|
||||
uiResourceUri: params.uiResourceUri,
|
||||
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
releaseRuntimeLease?.();
|
||||
logWarn(
|
||||
@@ -290,7 +332,16 @@ export function acquireMcpAppViewRequest(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMcpAppCanvasPayload(view: { viewId: string; title: string }) {
|
||||
export function buildMcpAppCanvasPayload(view: {
|
||||
viewId: string;
|
||||
title: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
resultMetaState?: "unavailable";
|
||||
}) {
|
||||
assertBoundedViewDescriptor(view);
|
||||
return {
|
||||
kind: "canvas",
|
||||
view: { id: view.viewId, title: view.title },
|
||||
@@ -300,7 +351,14 @@ export function buildMcpAppCanvasPayload(view: { viewId: string; title: string }
|
||||
preferred_height: 600,
|
||||
sandbox: "scripts",
|
||||
},
|
||||
mcpApp: { viewId: view.viewId },
|
||||
mcpApp: {
|
||||
viewId: view.viewId,
|
||||
serverName: view.serverName,
|
||||
toolName: view.toolName,
|
||||
uiResourceUri: view.uiResourceUri,
|
||||
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
|
||||
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,27 @@ describe("extractCanvasFromText", () => {
|
||||
kind: "canvas",
|
||||
view: { id: "cv_app" },
|
||||
presentation: { target: "assistant_message", sandbox: "scripts" },
|
||||
mcpApp: { viewId: "cv_app" },
|
||||
mcpApp: {
|
||||
viewId: "cv_app",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ viewId: "cv_app", mcpApp: { viewId: "cv_app" } });
|
||||
).toMatchObject({
|
||||
viewId: "cv_app",
|
||||
mcpApp: {
|
||||
viewId: "cv_app",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MCP App previews opaque while preserving model-visible results", () => {
|
||||
|
||||
@@ -9,6 +9,15 @@ import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js";
|
||||
type CanvasSurface = "assistant_message";
|
||||
type CanvasSandbox = "strict" | "scripts";
|
||||
|
||||
export type McpAppPreviewDescriptor = {
|
||||
viewId: string;
|
||||
serverName?: string;
|
||||
toolName?: string;
|
||||
uiResourceUri?: string;
|
||||
toolCallId?: string;
|
||||
resultMetaState?: "unavailable";
|
||||
};
|
||||
|
||||
type CanvasPreview = {
|
||||
kind: "canvas";
|
||||
surface: CanvasSurface;
|
||||
@@ -20,7 +29,7 @@ type CanvasPreview = {
|
||||
className?: string;
|
||||
style?: string;
|
||||
sandbox?: CanvasSandbox;
|
||||
mcpApp?: { viewId: string };
|
||||
mcpApp?: McpAppPreviewDescriptor;
|
||||
};
|
||||
|
||||
function getRecordStringField(
|
||||
@@ -47,6 +56,40 @@ function getNestedRecord(
|
||||
return asOptionalRecord(value);
|
||||
}
|
||||
|
||||
function coerceMcpAppDescriptor(
|
||||
record: Record<string, unknown> | undefined,
|
||||
): McpAppPreviewDescriptor | undefined {
|
||||
const viewId = getRecordStringField(record, "viewId");
|
||||
if (!viewId || viewId.length > 128) {
|
||||
return undefined;
|
||||
}
|
||||
const serverName = getRecordStringField(record, "serverName");
|
||||
const toolName = getRecordStringField(record, "toolName");
|
||||
const uiResourceUri = getRecordStringField(record, "uiResourceUri");
|
||||
const toolCallId = getRecordStringField(record, "toolCallId");
|
||||
const resultMetaState = record?.resultMetaState === "unavailable" ? "unavailable" : undefined;
|
||||
const hasCompleteDescriptor = Boolean(
|
||||
serverName &&
|
||||
serverName.length <= 256 &&
|
||||
toolName &&
|
||||
toolName.length <= 256 &&
|
||||
uiResourceUri?.startsWith("ui://") &&
|
||||
uiResourceUri.length <= 2048 &&
|
||||
toolCallId &&
|
||||
toolCallId.length <= 512,
|
||||
);
|
||||
return hasCompleteDescriptor
|
||||
? {
|
||||
viewId,
|
||||
serverName,
|
||||
toolName,
|
||||
uiResourceUri,
|
||||
toolCallId,
|
||||
...(resultMetaState ? { resultMetaState } : {}),
|
||||
}
|
||||
: { viewId };
|
||||
}
|
||||
|
||||
function normalizeSurface(value: string | undefined): CanvasSurface | undefined {
|
||||
return value === "assistant_message" ? value : undefined;
|
||||
}
|
||||
@@ -75,7 +118,8 @@ function coerceCanvasPreview(
|
||||
const view = getNestedRecord(record, "view");
|
||||
const source = getNestedRecord(record, "source");
|
||||
const mcpAppRecord = getNestedRecord(record, "mcpApp");
|
||||
const mcpAppViewId = getRecordStringField(mcpAppRecord, "viewId");
|
||||
const mcpApp = coerceMcpAppDescriptor(mcpAppRecord);
|
||||
const mcpAppViewId = mcpApp?.viewId;
|
||||
const requestedSurface =
|
||||
getRecordStringField(presentation, "target") ?? getRecordStringField(record, "target");
|
||||
const surface = requestedSurface ? normalizeSurface(requestedSurface) : "assistant_message";
|
||||
@@ -105,7 +149,7 @@ function coerceCanvasPreview(
|
||||
...(title ? { title } : {}),
|
||||
...(preferredHeight ? { preferredHeight } : {}),
|
||||
...(sandbox ? { sandbox } : {}),
|
||||
mcpApp: { viewId: mcpAppViewId },
|
||||
mcpApp,
|
||||
};
|
||||
}
|
||||
if (viewUrl) {
|
||||
@@ -120,7 +164,7 @@ function coerceCanvasPreview(
|
||||
...(className ? { className } : {}),
|
||||
...(style ? { style } : {}),
|
||||
...(sandbox ? { sandbox } : {}),
|
||||
...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}),
|
||||
...(mcpApp ? { mcpApp } : {}),
|
||||
};
|
||||
}
|
||||
const sourceType = getRecordStringField(source, "type")?.trim().toLowerCase();
|
||||
@@ -139,7 +183,7 @@ function coerceCanvasPreview(
|
||||
...(className ? { className } : {}),
|
||||
...(style ? { style } : {}),
|
||||
...(sandbox ? { sandbox } : {}),
|
||||
...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}),
|
||||
...(mcpApp ? { mcpApp } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
262
src/gateway/mcp-app-reconstruction.test.ts
Normal file
262
src/gateway/mcp-app-reconstruction.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMcpAppReconstructionData,
|
||||
findMcpAppReconstructionDataByVisit,
|
||||
} from "./mcp-app-reconstruction.js";
|
||||
|
||||
describe("MCP App transcript reconstruction", () => {
|
||||
it("reconstructs only a descriptor bound to its tool call and result", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "demo__show",
|
||||
arguments: { city: "Paris" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "demo__show",
|
||||
content: [
|
||||
{ type: "text", text: "ok" },
|
||||
{ type: "audio", data: "YXVkaW8=", mimeType: "audio/mpeg" },
|
||||
],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
structuredContent: { city: "Paris" },
|
||||
mcpAppPreview: {
|
||||
kind: "canvas",
|
||||
view: { id: "mcp-app-1" },
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-1",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(findMcpAppReconstructionData(messages, "mcp-app-1")).toEqual({
|
||||
descriptor: {
|
||||
viewId: "mcp-app-1",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
toolInput: { city: "Paris" },
|
||||
toolResult: {
|
||||
content: [
|
||||
{ type: "text", text: "ok" },
|
||||
{ type: "audio", data: "YXVkaW8=", mimeType: "audio/mpeg" },
|
||||
],
|
||||
structuredContent: { city: "Paris" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects client-selected descriptors that do not match transcript ownership", () => {
|
||||
expect(
|
||||
findMcpAppReconstructionData(
|
||||
[
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-other",
|
||||
toolName: "demo__show",
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-1",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"mcp-app-1",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds reused call IDs to the nearest preceding matching tool", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "shared", name: "other__tool", args: { secret: 1 } }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "shared", name: "demo__show", args: { page: 2 } }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "shared",
|
||||
toolName: "demo__show",
|
||||
content: [],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-reused",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "shared",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "shared", name: "demo__show", args: { page: 3 } }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findMcpAppReconstructionData(messages, "mcp-app-reused")?.toolInput).toEqual({
|
||||
page: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("declines restart reconstruction when app-only result metadata was not persisted", () => {
|
||||
expect(
|
||||
findMcpAppReconstructionData(
|
||||
[
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-old", name: "demo__show", args: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-old",
|
||||
toolName: "demo__show",
|
||||
content: [{ type: "text", text: "stale" }],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-meta",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-old",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "demo__show", args: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "demo__show",
|
||||
content: [],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-meta",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"mcp-app-meta",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a descriptor without its matching tool-call input", () => {
|
||||
expect(
|
||||
findMcpAppReconstructionData(
|
||||
[
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "demo__show",
|
||||
content: [],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-1",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"mcp-app-1",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("streams the full active transcript instead of limiting reconstruction to its tail", async () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "demo__show", args: { page: 1 } }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "demo__show",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {
|
||||
mcpServer: "demo",
|
||||
mcpTool: "show",
|
||||
mcpAppPreview: {
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-1",
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 2_500 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `later-${index}` }],
|
||||
})),
|
||||
];
|
||||
let passes = 0;
|
||||
const result = await findMcpAppReconstructionDataByVisit(async (visit) => {
|
||||
passes += 1;
|
||||
for (const message of messages) {
|
||||
visit(message);
|
||||
}
|
||||
}, "mcp-app-1");
|
||||
|
||||
expect(passes).toBe(2);
|
||||
expect(result?.toolInput).toEqual({ page: 1 });
|
||||
});
|
||||
});
|
||||
337
src/gateway/mcp-app-reconstruction.ts
Normal file
337
src/gateway/mcp-app-reconstruction.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
|
||||
import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import {
|
||||
fetchMcpAppView,
|
||||
getMcpAppViewLease,
|
||||
type McpAppViewLease,
|
||||
} from "../agents/mcp-ui-resource.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import { loadSessionEntry, visitSessionMessagesAsync } from "./session-utils.js";
|
||||
|
||||
const MCP_APP_RESTORE_IN_FLIGHT_KEY = Symbol.for("openclaw.mcpAppRestoreInFlight");
|
||||
|
||||
type McpAppDescriptor = {
|
||||
viewId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId: string;
|
||||
resultMetaState?: "unavailable";
|
||||
};
|
||||
|
||||
type ReconstructionData = {
|
||||
descriptor: McpAppDescriptor;
|
||||
toolInput: unknown;
|
||||
toolResult: CallToolResult;
|
||||
};
|
||||
|
||||
type ReconstructionResult = {
|
||||
runtime: SessionMcpRuntime;
|
||||
view: McpAppViewLease;
|
||||
};
|
||||
|
||||
type TranscriptVisit = (visit: (message: unknown) => void) => Promise<void>;
|
||||
type TranscriptResult = Omit<ReconstructionData, "toolInput"> & { modelToolName: string };
|
||||
type TranscriptResultRead =
|
||||
| { kind: "restorable"; value: TranscriptResult }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readDescriptor(value: unknown): McpAppDescriptor | undefined {
|
||||
const record = asRecord(value);
|
||||
const viewId = readString(record, "viewId");
|
||||
const serverName = readString(record, "serverName");
|
||||
const toolName = readString(record, "toolName");
|
||||
const uiResourceUri = readString(record, "uiResourceUri");
|
||||
const toolCallId = readString(record, "toolCallId");
|
||||
const rawResultMetaState = record?.resultMetaState;
|
||||
const resultMetaState = rawResultMetaState === "unavailable" ? rawResultMetaState : undefined;
|
||||
if (
|
||||
!viewId ||
|
||||
viewId.length > 128 ||
|
||||
!serverName ||
|
||||
serverName.length > 256 ||
|
||||
!toolName ||
|
||||
toolName.length > 256 ||
|
||||
!uiResourceUri?.startsWith("ui://") ||
|
||||
uiResourceUri.length > 2048 ||
|
||||
!toolCallId ||
|
||||
toolCallId.length > 512 ||
|
||||
(rawResultMetaState !== undefined && resultMetaState === undefined)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
viewId,
|
||||
serverName,
|
||||
toolName,
|
||||
uiResourceUri,
|
||||
toolCallId,
|
||||
...(resultMetaState ? { resultMetaState } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readToolInputFromMessage(
|
||||
value: unknown,
|
||||
toolCallId: string,
|
||||
modelToolName: string,
|
||||
): { found: true; input: unknown } | undefined {
|
||||
const message = asRecord(value);
|
||||
if (readString(message, "role")?.toLowerCase() !== "assistant") {
|
||||
return undefined;
|
||||
}
|
||||
const content = Array.isArray(message?.content) ? message.content : [];
|
||||
for (const blockValue of content) {
|
||||
const block = asRecord(blockValue);
|
||||
if ((readString(block, "id") ?? readString(block, "toolCallId")) !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const type = readString(block, "type")?.toLowerCase();
|
||||
if (type !== "toolcall" && type !== "tool_call" && type !== "tooluse" && type !== "tool_use") {
|
||||
continue;
|
||||
}
|
||||
const blockToolName =
|
||||
readString(block, "name") ?? readString(block, "toolName") ?? readString(block, "tool_name");
|
||||
if (blockToolName !== modelToolName) {
|
||||
continue;
|
||||
}
|
||||
return { found: true, input: block?.arguments ?? block?.input ?? block?.args ?? {} };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCallToolResult(message: Record<string, unknown>, details: Record<string, unknown>) {
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content.flatMap((value) => {
|
||||
const parsed = ContentBlockSchema.safeParse(value);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
content,
|
||||
...(details.structuredContent !== undefined
|
||||
? { structuredContent: details.structuredContent }
|
||||
: {}),
|
||||
...(message.isError === true || details.status === "error" ? { isError: true } : {}),
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
function readTranscriptResult(value: unknown, viewId: string): TranscriptResultRead | undefined {
|
||||
const message = asRecord(value);
|
||||
if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") {
|
||||
return undefined;
|
||||
}
|
||||
const details = asRecord(message.details);
|
||||
if (!details) {
|
||||
return undefined;
|
||||
}
|
||||
const preview = asRecord(details.mcpAppPreview);
|
||||
const rawDescriptor = asRecord(preview?.mcpApp);
|
||||
if (readString(rawDescriptor, "viewId") !== viewId) {
|
||||
return undefined;
|
||||
}
|
||||
const descriptor = readDescriptor(rawDescriptor);
|
||||
const modelToolName = readString(message, "toolName") ?? readString(message, "tool_name");
|
||||
if (!descriptor || !modelToolName) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
if (
|
||||
readString(message, "toolCallId") !== descriptor.toolCallId ||
|
||||
readString(details, "mcpServer") !== descriptor.serverName ||
|
||||
readString(details, "mcpTool") !== descriptor.toolName ||
|
||||
descriptor.resultMetaState === "unavailable"
|
||||
) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
return {
|
||||
kind: "restorable",
|
||||
value: { descriptor, modelToolName, toolResult: readCallToolResult(message, details) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Finds a server-authored descriptor and its canonical tool call/result pair. */
|
||||
export function findMcpAppReconstructionData(
|
||||
messages: unknown[],
|
||||
viewId: string,
|
||||
): ReconstructionData | undefined {
|
||||
for (let resultIndex = messages.length - 1; resultIndex >= 0; resultIndex -= 1) {
|
||||
const read = readTranscriptResult(messages[resultIndex], viewId);
|
||||
if (!read) {
|
||||
continue;
|
||||
}
|
||||
if (read.kind === "unavailable") {
|
||||
return undefined;
|
||||
}
|
||||
const result = read.value;
|
||||
let input: { found: true; input: unknown } | undefined;
|
||||
for (let inputIndex = resultIndex - 1; inputIndex >= 0; inputIndex -= 1) {
|
||||
input = readToolInputFromMessage(
|
||||
messages[inputIndex],
|
||||
result.descriptor.toolCallId,
|
||||
result.modelToolName,
|
||||
);
|
||||
if (input) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const { modelToolName: _modelToolName, ...reconstruction } = result;
|
||||
return {
|
||||
...reconstruction,
|
||||
toolInput: input.input,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Searches the full active transcript without retaining its messages in memory. */
|
||||
export async function findMcpAppReconstructionDataByVisit(
|
||||
visitTranscript: TranscriptVisit,
|
||||
viewId: string,
|
||||
): Promise<ReconstructionData | undefined> {
|
||||
let resultRead: TranscriptResultRead | undefined;
|
||||
let resultIndex = -1;
|
||||
let messageIndex = 0;
|
||||
await visitTranscript((message) => {
|
||||
const read = readTranscriptResult(message, viewId);
|
||||
if (read) {
|
||||
resultRead = read;
|
||||
resultIndex = messageIndex;
|
||||
}
|
||||
messageIndex += 1;
|
||||
});
|
||||
if (!resultRead || resultRead.kind === "unavailable") {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedResult = resultRead.value;
|
||||
let toolInput: unknown;
|
||||
let foundInput = false;
|
||||
messageIndex = 0;
|
||||
await visitTranscript((message) => {
|
||||
if (messageIndex >= resultIndex) {
|
||||
messageIndex += 1;
|
||||
return;
|
||||
}
|
||||
const input = readToolInputFromMessage(
|
||||
message,
|
||||
resolvedResult.descriptor.toolCallId,
|
||||
resolvedResult.modelToolName,
|
||||
);
|
||||
if (input) {
|
||||
foundInput = true;
|
||||
toolInput = input.input;
|
||||
}
|
||||
messageIndex += 1;
|
||||
});
|
||||
if (!foundInput) {
|
||||
return undefined;
|
||||
}
|
||||
const { modelToolName: _modelToolName, ...reconstruction } = resolvedResult;
|
||||
return { ...reconstruction, toolInput };
|
||||
}
|
||||
|
||||
function getRestoreInFlight(): Map<string, Promise<ReconstructionResult | undefined>> {
|
||||
const state = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = state[MCP_APP_RESTORE_IN_FLIGHT_KEY] as
|
||||
| Map<string, Promise<ReconstructionResult | undefined>>
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = new Map<string, Promise<ReconstructionResult | undefined>>();
|
||||
state[MCP_APP_RESTORE_IN_FLIGHT_KEY] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
async function restoreMcpAppViewOnce(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
viewId: string;
|
||||
}): Promise<ReconstructionResult | undefined> {
|
||||
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const loaded = loadSessionEntry(params.sessionKey, { agentId });
|
||||
const sessionId = loaded.entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const transcriptScope = {
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
storePath: loaded.storePath,
|
||||
sessionEntry: loaded.entry,
|
||||
};
|
||||
const data = await findMcpAppReconstructionDataByVisit(async (visit) => {
|
||||
await visitSessionMessagesAsync(transcriptScope, (message) => visit(message), {
|
||||
mode: "full",
|
||||
reason: "MCP App restart reconstruction",
|
||||
cache: "reuse",
|
||||
});
|
||||
}, params.viewId);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
const runtime = await getOrCreateSessionMcpRuntime({
|
||||
sessionId,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
|
||||
agentDir: resolveAgentDir(params.cfg, agentId),
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (runtime.mcpAppsEnabled !== true) {
|
||||
return undefined;
|
||||
}
|
||||
await fetchMcpAppView({
|
||||
runtime,
|
||||
serverName: data.descriptor.serverName,
|
||||
toolName: data.descriptor.toolName,
|
||||
uiResourceUri: data.descriptor.uiResourceUri,
|
||||
toolCallId: data.descriptor.toolCallId,
|
||||
toolInput: data.toolInput,
|
||||
toolResult: data.toolResult,
|
||||
viewId: data.descriptor.viewId,
|
||||
// A reconstructed preview can render and read its owning server resources,
|
||||
// but cannot call tools without a fresh run carrying current effective policy.
|
||||
allowedAppToolNames: new Set(),
|
||||
});
|
||||
const view = getMcpAppViewLease(params.viewId, runtime);
|
||||
return view ? { runtime, view } : undefined;
|
||||
}
|
||||
|
||||
export async function restoreMcpAppView(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
viewId: string;
|
||||
}): Promise<ReconstructionResult | undefined> {
|
||||
const key = `${params.sessionKey}\0${params.viewId}`;
|
||||
const inFlight = getRestoreInFlight();
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) {
|
||||
return await existing;
|
||||
}
|
||||
const pending = restoreMcpAppViewOnce(params).finally(() => {
|
||||
if (inFlight.get(key) === pending) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, pending);
|
||||
return await pending;
|
||||
}
|
||||
@@ -26,15 +26,13 @@ describe("MCP App sandbox HTTP origin", () => {
|
||||
expect(String(csp)).toContain("connect-src https://api.example.com");
|
||||
expect(String(csp)).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com");
|
||||
expect(String(csp)).toContain("font-src 'self' https://cdn.example.com");
|
||||
expect(String(csp)).not.toContain("frame-ancestors");
|
||||
expect(String(csp)).toContain("frame-ancestors");
|
||||
expect(result.setHeader).not.toHaveBeenCalledWith("X-Frame-Options", expect.anything());
|
||||
expect(result.setHeader).toHaveBeenCalledWith(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), clipboard-write=()",
|
||||
);
|
||||
expect(result.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ui/notifications/sandbox-proxy-ready"),
|
||||
);
|
||||
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("document.referrer"));
|
||||
});
|
||||
|
||||
it("supports HEAD and rejects other paths, methods, and malformed policy", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
|
||||
completeDeferredSessionMcpRuntimeRetirement: vi.fn(),
|
||||
getMcpAppViewLease: vi.fn(),
|
||||
peekSessionMcpRuntime: vi.fn(),
|
||||
restoreMcpAppView: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/mcp-ui-resource.js", () => ({
|
||||
@@ -18,6 +19,9 @@ vi.mock("../../agents/agent-bundle-mcp-runtime.js", () => ({
|
||||
completeDeferredSessionMcpRuntimeRetirement: mocks.completeDeferredSessionMcpRuntimeRetirement,
|
||||
peekSessionMcpRuntime: mocks.peekSessionMcpRuntime,
|
||||
}));
|
||||
vi.mock("../mcp-app-reconstruction.js", () => ({
|
||||
restoreMcpAppView: mocks.restoreMcpAppView,
|
||||
}));
|
||||
|
||||
import { mcpAppHandlers } from "./mcp-app.js";
|
||||
|
||||
@@ -73,7 +77,11 @@ function runtime() {
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(method: keyof typeof mcpAppHandlers, params: Record<string, unknown>) {
|
||||
async function invoke(
|
||||
method: keyof typeof mcpAppHandlers,
|
||||
params: Record<string, unknown>,
|
||||
mcpAppsEnabled = true,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
mcpAppHandlers[method],
|
||||
@@ -84,7 +92,7 @@ async function invoke(method: keyof typeof mcpAppHandlers, params: Record<string
|
||||
context: {
|
||||
getMcpAppSandboxPort: () => 18790,
|
||||
getRuntimeConfig: () => ({
|
||||
mcp: { apps: { enabled: true, sandboxOrigin: "https://apps.example.com" } },
|
||||
mcp: { apps: { enabled: mcpAppsEnabled, sandboxOrigin: "https://apps.example.com" } },
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
@@ -100,6 +108,7 @@ describe("MCP App gateway bridge", () => {
|
||||
mocks.getMcpAppViewLease.mockReset().mockReturnValue(view);
|
||||
mocks.completeDeferredSessionMcpRuntimeRetirement.mockReset().mockResolvedValue(false);
|
||||
mocks.peekSessionMcpRuntime.mockReset().mockReturnValue(runtime());
|
||||
mocks.restoreMcpAppView.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("returns the ephemeral view payload only for the bound session", async () => {
|
||||
@@ -165,12 +174,49 @@ describe("MCP App gateway bridge", () => {
|
||||
expect(denied.mock.calls[0]?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("never creates a runtime for an expired view", async () => {
|
||||
it("rejects views that are not backed by the transcript", async () => {
|
||||
mocks.getMcpAppViewLease.mockReturnValue(undefined);
|
||||
const respond = await invoke("mcp.app.view", {
|
||||
sessionKey: "agent:main:main",
|
||||
viewId: "expired",
|
||||
});
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(mocks.restoreMcpAppView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
viewId: "expired",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects disabled Apps before attempting transcript reconstruction", async () => {
|
||||
mocks.peekSessionMcpRuntime.mockReturnValue(undefined);
|
||||
|
||||
const respond = await invoke(
|
||||
"mcp.app.view",
|
||||
{ sessionKey: "agent:main:main", viewId: "cv_app" },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(mocks.restoreMcpAppView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores a transcript-backed view after a Gateway restart", async () => {
|
||||
const restoredRuntime = runtime();
|
||||
const restoredView = { ...view, runtime: restoredRuntime, allowedAppToolNames: new Set() };
|
||||
mocks.peekSessionMcpRuntime.mockReturnValue(undefined);
|
||||
mocks.restoreMcpAppView.mockResolvedValue({
|
||||
runtime: restoredRuntime,
|
||||
view: restoredView,
|
||||
});
|
||||
|
||||
const respond = await invoke("mcp.app.view", {
|
||||
sessionKey: "agent:main:main",
|
||||
viewId: "cv_app",
|
||||
});
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
expect(respond.mock.calls[0]?.[1]).toMatchObject({ html: "<html>demo</html>" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
getMcpAppViewLease,
|
||||
type McpAppViewLease,
|
||||
} from "../../agents/mcp-ui-resource.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { logWarn } from "../../logger.js";
|
||||
import { restoreMcpAppView } from "../mcp-app-reconstruction.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function requireString(params: Record<string, unknown>, key: string): string {
|
||||
@@ -50,20 +52,33 @@ function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
|
||||
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
|
||||
}
|
||||
|
||||
function requireActiveView(params: Record<string, unknown>): {
|
||||
async function requireActiveView(
|
||||
params: Record<string, unknown>,
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<{
|
||||
runtime: SessionMcpRuntime;
|
||||
view: McpAppViewLease;
|
||||
} {
|
||||
}> {
|
||||
const sessionKey = requireString(params, "sessionKey");
|
||||
const viewId = requireString(params, "viewId");
|
||||
const runtime = peekSessionMcpRuntime({ sessionKey });
|
||||
if (!runtime || runtime.mcpAppsEnabled !== true) {
|
||||
const existingRuntime = peekSessionMcpRuntime({ sessionKey });
|
||||
if (
|
||||
(existingRuntime && existingRuntime.mcpAppsEnabled !== true) ||
|
||||
(cfg && cfg.mcp?.apps?.enabled !== true)
|
||||
) {
|
||||
throw new Error("MCP App runtime is unavailable");
|
||||
}
|
||||
const view = getMcpAppViewLease(viewId, runtime);
|
||||
if (!view) {
|
||||
const existingView = existingRuntime ? getMcpAppViewLease(viewId, existingRuntime) : undefined;
|
||||
const restored =
|
||||
existingRuntime?.mcpAppsEnabled === true && existingView
|
||||
? { runtime: existingRuntime, view: existingView }
|
||||
: cfg
|
||||
? await restoreMcpAppView({ cfg, sessionKey, viewId })
|
||||
: undefined;
|
||||
if (!restored) {
|
||||
throw new Error("MCP App view expired or is not authorized for this session");
|
||||
}
|
||||
const { runtime, view } = restored;
|
||||
runtime.markUsed();
|
||||
return { runtime, view };
|
||||
}
|
||||
@@ -72,8 +87,9 @@ async function withActiveView<T>(
|
||||
params: Record<string, unknown>,
|
||||
kind: "read" | "tool",
|
||||
operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise<T> | T,
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<T> {
|
||||
const active = requireActiveView(params);
|
||||
const active = await requireActiveView(params, cfg);
|
||||
const release = acquireMcpAppViewRequest(active.view, kind);
|
||||
const releaseRuntimeLease = active.runtime.acquireLease?.();
|
||||
try {
|
||||
@@ -120,22 +136,27 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
|
||||
await handle(
|
||||
respond,
|
||||
async () =>
|
||||
await withActiveView(params, "read", ({ view }) => {
|
||||
const sandboxPort = context.getMcpAppSandboxPort?.();
|
||||
if (sandboxPort === undefined) {
|
||||
throw new Error("MCP App sandbox listener is unavailable; restart the Gateway");
|
||||
}
|
||||
const configuredOrigin = context.getRuntimeConfig().mcp?.apps?.sandboxOrigin;
|
||||
return {
|
||||
sandboxUrl: buildMcpAppSandboxPath(view.csp),
|
||||
sandboxPort,
|
||||
...(configuredOrigin ? { sandboxOrigin: new URL(configuredOrigin).origin } : {}),
|
||||
html: view.html,
|
||||
...(view.csp ? { csp: view.csp } : {}),
|
||||
toolInput: view.toolInput,
|
||||
toolResult: view.toolResult,
|
||||
};
|
||||
}),
|
||||
await withActiveView(
|
||||
params,
|
||||
"read",
|
||||
({ view }) => {
|
||||
const sandboxPort = context.getMcpAppSandboxPort?.();
|
||||
if (sandboxPort === undefined) {
|
||||
throw new Error("MCP App sandbox listener is unavailable; restart the Gateway");
|
||||
}
|
||||
const configuredOrigin = context.getRuntimeConfig().mcp?.apps?.sandboxOrigin;
|
||||
return {
|
||||
sandboxUrl: buildMcpAppSandboxPath(view.csp),
|
||||
sandboxPort,
|
||||
...(configuredOrigin ? { sandboxOrigin: new URL(configuredOrigin).origin } : {}),
|
||||
html: view.html,
|
||||
...(view.csp ? { csp: view.csp } : {}),
|
||||
toolInput: view.toolInput,
|
||||
toolResult: view.toolResult,
|
||||
};
|
||||
},
|
||||
context.getRuntimeConfig(),
|
||||
),
|
||||
);
|
||||
},
|
||||
"mcp.app.callTool": async ({ respond, params }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { McpAppView } from "./mcp-app-view.ts";
|
||||
import { McpAppView, waitForMcpAppHandlerRegistration } from "./mcp-app-view.ts";
|
||||
|
||||
const MCP_APP_VIEW_ELEMENT_NAME = `test-mcp-app-view-${crypto.randomUUID()}`;
|
||||
|
||||
@@ -34,4 +34,44 @@ describe("mcp-app-view localization", () => {
|
||||
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
|
||||
.toBe("Aplicativo MCP indisponível: MCP App gateway unavailable");
|
||||
});
|
||||
|
||||
it("crosses two paint boundaries before delivering initial tool notifications", async () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
const pending = waitForMcpAppHandlerRegistration((callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
let resolved = false;
|
||||
void pending.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
frames.shift()?.(0);
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
frames.shift()?.(16);
|
||||
await pending;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a delayed fallback when animation frames are suspended", async () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
let fallback: (() => void) | undefined;
|
||||
const pending = waitForMcpAppHandlerRegistration(
|
||||
(callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
},
|
||||
(callback, delayMs) => {
|
||||
expect(delayMs).toBe(1_000);
|
||||
fallback = callback;
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
fallback?.();
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,25 @@ type HostContext = NonNullable<
|
||||
type HostCapabilities = ConstructorParameters<typeof AppBridge>[2];
|
||||
type HostSandboxCsp = NonNullable<NonNullable<HostCapabilities["sandbox"]>["csp"]>;
|
||||
|
||||
type ScheduleFrame = (callback: FrameRequestCallback) => number;
|
||||
type ScheduleFallback = (callback: () => void, delayMs: number) => number;
|
||||
|
||||
export async function waitForMcpAppHandlerRegistration(
|
||||
scheduleFrame: ScheduleFrame = window.requestAnimationFrame.bind(window),
|
||||
scheduleFallback: ScheduleFallback = window.setTimeout.bind(window),
|
||||
): Promise<void> {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
scheduleFrame(() => {
|
||||
scheduleFrame(() => resolve());
|
||||
});
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
scheduleFallback(resolve, 1_000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function hostContext(element: Element | undefined, height: number): HostContext {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const touch = navigator.maxTouchPoints > 0 || window.matchMedia?.("(pointer: coarse)").matches;
|
||||
@@ -224,7 +243,9 @@ export class McpAppView extends LitElement {
|
||||
}
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.title = this.title || t("mcpApp.title");
|
||||
iframe.referrerPolicy = "no-referrer";
|
||||
// The isolated proxy binds its parent before accepting messages. Only the
|
||||
// Control UI origin is disclosed; path/query data remains suppressed.
|
||||
iframe.referrerPolicy = "origin";
|
||||
iframe.style.height = `${this.height}px`;
|
||||
// The proxy listener is a dedicated origin that never serves host data,
|
||||
// so Apps retain their required origin capabilities without reaching Control UI.
|
||||
@@ -315,6 +336,10 @@ export class McpAppView extends LitElement {
|
||||
window.setTimeout(() => reject(new Error("MCP App initialization timed out")), 15_000);
|
||||
}),
|
||||
]);
|
||||
await waitForMcpAppHandlerRegistration();
|
||||
if (generation !== this.setupGeneration) {
|
||||
return;
|
||||
}
|
||||
await bridge.sendToolInput({
|
||||
arguments:
|
||||
payload.toolInput &&
|
||||
|
||||
@@ -163,7 +163,13 @@ export type ToolCard = {
|
||||
className?: string;
|
||||
style?: string;
|
||||
sandbox?: "strict" | "scripts";
|
||||
mcpApp?: { viewId: string };
|
||||
mcpApp?: {
|
||||
viewId: string;
|
||||
serverName?: string;
|
||||
toolName?: string;
|
||||
uiResourceUri?: string;
|
||||
toolCallId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -105,7 +105,17 @@ function coerceCanvasPreview(
|
||||
? { sandbox: preview.sandbox }
|
||||
: {}),
|
||||
...(typeof mcpApp?.viewId === "string" && mcpApp.viewId.trim()
|
||||
? { mcpApp: { viewId: mcpApp.viewId } }
|
||||
? {
|
||||
mcpApp: {
|
||||
viewId: mcpApp.viewId,
|
||||
...(typeof mcpApp.serverName === "string" ? { serverName: mcpApp.serverName } : {}),
|
||||
...(typeof mcpApp.toolName === "string" ? { toolName: mcpApp.toolName } : {}),
|
||||
...(typeof mcpApp.uiResourceUri === "string"
|
||||
? { uiResourceUri: mcpApp.uiResourceUri }
|
||||
: {}),
|
||||
...(typeof mcpApp.toolCallId === "string" ? { toolCallId: mcpApp.toolCallId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1803,6 +1803,38 @@ describe("buildChatItems", () => {
|
||||
expect(canvasBlocksIn(groupAt(groups, 1))).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a live App preview on an assistant search match", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "assistant", content: "Matching preview", timestamp: 1_000 }],
|
||||
toolMessages: [mcpAppResult("mcp-app-search", "call-search", 1_001)],
|
||||
searchOpen: true,
|
||||
searchQuery: "matching",
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
expect(canvasBlocksIn(groupAt(groups, 0))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a persisted App preview on an assistant search match", () => {
|
||||
for (const showToolCalls of [false, true]) {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "Show the App", timestamp: 1_000 },
|
||||
mcpAppResult("mcp-app-persisted-search", "call-persisted-search", 1_001),
|
||||
{ role: "assistant", content: "Matching preview", timestamp: 1_002 },
|
||||
],
|
||||
toolMessages: [],
|
||||
searchOpen: true,
|
||||
searchQuery: "matching",
|
||||
showToolCalls,
|
||||
});
|
||||
|
||||
const assistant = groups.find((group) => group.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a metadata-only assistant anchor when lifting canvas previews", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
@@ -1842,6 +1874,184 @@ describe("buildChatItems", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("creates an assistant anchor for a silent App turn", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "First request", timestamp: 1_000 },
|
||||
{ role: "assistant", content: "First response", timestamp: 1_001 },
|
||||
{ role: "user", content: "Show the App", timestamp: 2_000 },
|
||||
],
|
||||
toolMessages: [mcpAppResult("mcp-app-silent", "call-silent", 2_001)],
|
||||
queue: [
|
||||
{
|
||||
id: "queued-next-turn",
|
||||
text: "Next request",
|
||||
createdAt: 2_100,
|
||||
sendSubmittedAtMs: 2_100,
|
||||
sendState: "sending",
|
||||
},
|
||||
],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
const assistants = groups.filter((group) => group.role === "assistant");
|
||||
expect(assistants).toHaveLength(2);
|
||||
expect(canvasBlocksIn(groupAt(assistants, 0))).toStrictEqual([]);
|
||||
expect(canvasBlocksIn(groupAt(assistants, 1))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps an earlier silent App preview before the next user turn", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "Show the App", timestamp: 1_000 },
|
||||
{ role: "user", content: "Next request", timestamp: 2_000 },
|
||||
],
|
||||
toolMessages: [mcpAppResult("mcp-app-earlier", "call-earlier", 1_001)],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual(["user", "assistant", "user"]);
|
||||
expect(canvasBlocksIn(groupAt(groups, 1))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("places an App preview after its queued user prompt", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "First request", timestamp: 1_000 },
|
||||
{ role: "assistant", content: "First response", timestamp: 1_001 },
|
||||
],
|
||||
queue: [
|
||||
{
|
||||
id: "queued-app-turn",
|
||||
text: "Show the App",
|
||||
createdAt: 2_000,
|
||||
sendSubmittedAtMs: 2_000,
|
||||
sendState: "waiting-model",
|
||||
},
|
||||
{
|
||||
id: "queued-future-turn",
|
||||
text: "Later request",
|
||||
createdAt: 2_001,
|
||||
sendSubmittedAtMs: 2_001,
|
||||
sendState: "waiting-reconnect",
|
||||
},
|
||||
],
|
||||
toolMessages: [mcpAppResult("mcp-app-queued", "call-queued", 2_002)],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
]);
|
||||
expect(canvasBlocksIn(groupAt(groups, 3))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("restores a persisted App preview without the live tool cache", () => {
|
||||
for (const showToolCalls of [false, true]) {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "Show the App", timestamp: 1_000 },
|
||||
mcpAppResult("mcp-app-persisted", "call-persisted", 1_001),
|
||||
],
|
||||
toolMessages: [],
|
||||
showToolCalls,
|
||||
});
|
||||
|
||||
const assistant = groups.find((group) => group.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates persisted and live copies of an App preview", () => {
|
||||
const result = mcpAppResult("mcp-app-overlap", "call-overlap", 1_001);
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "user", content: "Show the App", timestamp: 1_000 }, result],
|
||||
toolMessages: [result],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
const assistant = groups.find((group) => group.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("deduplicates timestamp-less persisted and live copies in the same turn", () => {
|
||||
const persisted = {
|
||||
...mcpAppResult("mcp-app-untimestamped", "call-untimestamped", 1_001),
|
||||
timestamp: undefined,
|
||||
};
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "user", content: "Show the App", timestamp: 1_000 }, persisted],
|
||||
toolMessages: [mcpAppLiveResult("mcp-app-untimestamped", "call-untimestamped", 1_001)],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
expect(groups.flatMap((group) => canvasBlocksIn(group))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps distinct App previews when a tool-call ID is reused", () => {
|
||||
const first = {
|
||||
...mcpAppResult("mcp-app-first", "call-reused", 1_001),
|
||||
timestamp: undefined,
|
||||
};
|
||||
const second = mcpAppLiveResult("mcp-app-second", "call-reused", undefined);
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "First App", timestamp: 1_000 },
|
||||
first,
|
||||
{ role: "user", content: "Second App", timestamp: 2_000 },
|
||||
],
|
||||
toolMessages: [second],
|
||||
showToolCalls: false,
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual(["user", "assistant", "user", "assistant"]);
|
||||
expect(canvasBlocksIn(groupAt(groups, 1))).toHaveLength(1);
|
||||
expect(canvasBlocksIn(groupAt(groups, 3))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a persisted App preview with its history-cutoff assistant", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "Show the App", timestamp: 1_000 },
|
||||
mcpAppResult("mcp-app-cutoff", "call-cutoff", 1_001),
|
||||
{ role: "assistant", content: "Done", timestamp: 1_002 },
|
||||
],
|
||||
toolMessages: [],
|
||||
showToolCalls: false,
|
||||
historyRenderLimit: 1,
|
||||
});
|
||||
|
||||
const assistant = groups.find((group) => group.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not expand a persisted preview across a cutoff user turn", () => {
|
||||
const firstResult = {
|
||||
...mcpAppResult("mcp-app-first", "call-first", 1_001),
|
||||
timestamp: undefined,
|
||||
};
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{ role: "user", content: "First request", timestamp: 1_000 },
|
||||
firstResult,
|
||||
{ role: "user", content: "Second request", timestamp: 2_000 },
|
||||
{ role: "assistant", content: "Second response", timestamp: 2_001 },
|
||||
],
|
||||
toolMessages: [{ ...firstResult }],
|
||||
showToolCalls: false,
|
||||
historyRenderLimit: 2,
|
||||
});
|
||||
|
||||
expect(groups.flatMap((group) => canvasBlocksIn(group))).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not lift generic view handles from non-canvas payloads", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
@@ -2069,6 +2279,51 @@ function createAssistantCanvasBlock(params: { suffix: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
function mcpAppResult(viewId: string, toolCallId: string, timestamp: number) {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: "demo__show",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {
|
||||
mcpAppPreview: {
|
||||
kind: "canvas",
|
||||
view: { id: viewId, title: "Demo App" },
|
||||
presentation: { target: "assistant_message", sandbox: "scripts" },
|
||||
mcpApp: {
|
||||
viewId,
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app.html",
|
||||
toolCallId,
|
||||
},
|
||||
},
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function mcpAppLiveResult(viewId: string, toolCallId: string, timestamp: number | undefined) {
|
||||
const persisted = mcpAppResult(viewId, toolCallId, timestamp ?? 0);
|
||||
return {
|
||||
role: "assistant",
|
||||
toolCallId,
|
||||
runId: "run-live",
|
||||
content: [
|
||||
{ type: "toolcall", name: "demo__show", arguments: {} },
|
||||
{
|
||||
type: "toolresult",
|
||||
name: "demo__show",
|
||||
text: "ok",
|
||||
details: persisted.details,
|
||||
},
|
||||
],
|
||||
...(timestamp == null ? {} : { timestamp }),
|
||||
__openclawToolStreamLive: true,
|
||||
__openclawToolStreamResultReceived: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tool turn outcome annotation (#89683)", () => {
|
||||
function failedTool(timestamp: number) {
|
||||
return {
|
||||
|
||||
@@ -157,13 +157,36 @@ function messageMatchesSearchQuery(message: unknown, query: string): boolean {
|
||||
return text.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
function extractChatMessagePreview(toolMessage: unknown): {
|
||||
function turnHasMatchingAssistant(
|
||||
messages: unknown[],
|
||||
sourceIndex: number,
|
||||
searchQuery: string,
|
||||
): boolean {
|
||||
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
const normalized = safeNormalizeMessage(message);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
|
||||
if (role === "user" || role === "system") {
|
||||
return false;
|
||||
}
|
||||
if (role === "assistant" && messageMatchesSearchQuery(message, searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type ChatMessagePreview = {
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
} | null {
|
||||
const normalized = safeNormalizeMessage(toolMessage);
|
||||
if (!normalized) {
|
||||
};
|
||||
|
||||
function extractChatMessagePreview(toolMessage: unknown): ChatMessagePreview | null {
|
||||
if (!safeNormalizeMessage(toolMessage)) {
|
||||
return null;
|
||||
}
|
||||
const cards = extractToolCardsCached(toolMessage, "preview");
|
||||
@@ -173,7 +196,7 @@ function extractChatMessagePreview(toolMessage: unknown): {
|
||||
return {
|
||||
preview: card.preview,
|
||||
text: card.outputText ?? null,
|
||||
timestamp: normalized.timestamp ?? null,
|
||||
timestamp: rawMessageTimestamp(toolMessage),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -189,16 +212,88 @@ function extractChatMessagePreview(toolMessage: unknown): {
|
||||
if (preview?.kind !== "canvas") {
|
||||
return null;
|
||||
}
|
||||
return { preview, text: text ?? null, timestamp: normalized.timestamp ?? null };
|
||||
return { preview, text: text ?? null, timestamp: rawMessageTimestamp(toolMessage) };
|
||||
}
|
||||
|
||||
function canvasPreviewBaseIdentity(message: unknown, source: ChatMessagePreview): string | null {
|
||||
const toolCallId = resolveMessageToolUseId(asRecord(message) ?? {});
|
||||
const previewId = source.preview.viewId ?? source.preview.url;
|
||||
return toolCallId && previewId ? JSON.stringify([toolCallId, previewId]) : null;
|
||||
}
|
||||
|
||||
function createCanvasAssistantMessage(
|
||||
source: ChatMessagePreview,
|
||||
timestamp = source.timestamp,
|
||||
): unknown {
|
||||
return appendCanvasBlockToAssistantMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [],
|
||||
...(timestamp != null ? { timestamp } : {}),
|
||||
},
|
||||
source.preview,
|
||||
source.text,
|
||||
);
|
||||
}
|
||||
|
||||
function transcriptPositionTimestamp(messages: unknown[], sourceIndex: number): number | null {
|
||||
let previous: number | null = null;
|
||||
for (let index = sourceIndex - 1; index >= 0; index -= 1) {
|
||||
previous = rawMessageTimestamp(messages[index]);
|
||||
if (previous != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let next: number | null = null;
|
||||
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
|
||||
next = rawMessageTimestamp(messages[index]);
|
||||
if (next != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (previous != null && next != null) {
|
||||
return previous < next ? Math.min(previous + 1, next) : next;
|
||||
}
|
||||
if (previous != null) {
|
||||
return previous + 1;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function findNearestAssistantMessageIndex(
|
||||
items: ChatItem[],
|
||||
toolTimestamp: number | null,
|
||||
): number | null {
|
||||
let currentTurnStart = 0;
|
||||
let currentTurnEnd = items.length;
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
if (item?.kind !== "message") {
|
||||
continue;
|
||||
}
|
||||
const normalized = safeNormalizeMessage(item.message);
|
||||
if (!normalized || normalizeRoleForGrouping(normalized.role).toLowerCase() !== "user") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
toolTimestamp != null &&
|
||||
normalized.timestamp != null &&
|
||||
normalized.timestamp > toolTimestamp
|
||||
) {
|
||||
currentTurnEnd = index;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
toolTimestamp == null ||
|
||||
normalized.timestamp == null ||
|
||||
normalized.timestamp <= toolTimestamp
|
||||
) {
|
||||
currentTurnStart = index + 1;
|
||||
}
|
||||
}
|
||||
const assistantEntries = items
|
||||
.map((item, index) => {
|
||||
if (item.kind !== "message") {
|
||||
if (index < currentTurnStart || index >= currentTurnEnd || item.kind !== "message") {
|
||||
return null;
|
||||
}
|
||||
const message = item.message as Record<string, unknown>;
|
||||
@@ -245,6 +340,28 @@ function findNearestAssistantMessageIndex(
|
||||
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
|
||||
}
|
||||
|
||||
function findCanvasInsertionIndex(items: ChatItem[], toolTimestamp: number | null): number {
|
||||
if (toolTimestamp == null) {
|
||||
return items.length;
|
||||
}
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
if (item?.kind !== "message") {
|
||||
continue;
|
||||
}
|
||||
const normalized = safeNormalizeMessage(item.message);
|
||||
if (
|
||||
normalized &&
|
||||
normalizeRoleForGrouping(normalized.role).toLowerCase() === "user" &&
|
||||
normalized.timestamp != null &&
|
||||
normalized.timestamp > toolTimestamp
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return items.length;
|
||||
}
|
||||
|
||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
const result: Array<ChatItem | MessageGroup> = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
@@ -628,7 +745,12 @@ function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
|
||||
}
|
||||
|
||||
function assistantGroupHasReplyText(group: MessageGroup): boolean {
|
||||
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
|
||||
return group.messages.some(({ message }) => {
|
||||
if (extractTextCached(message)?.trim()) {
|
||||
return true;
|
||||
}
|
||||
return safeNormalizeMessage(message)?.content.some((block) => block.type === "canvas") ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
function assistantGroupIsForwardedBoundary(group: MessageGroup): boolean {
|
||||
@@ -1108,6 +1230,34 @@ function resolveHistoryStartIndex(
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
function expandHistoryStartForPersistedPreviews(messages: unknown[], historyStart: number): number {
|
||||
const firstVisible = safeNormalizeMessage(messages[historyStart]);
|
||||
if (!firstVisible || normalizeRoleForGrouping(firstVisible.role).toLowerCase() !== "assistant") {
|
||||
return historyStart;
|
||||
}
|
||||
let expandedStart = historyStart;
|
||||
for (let index = historyStart - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const normalized = safeNormalizeMessage(message);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRole = normalized.role.toLowerCase();
|
||||
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
|
||||
if (role === "user" || role === "system") {
|
||||
break;
|
||||
}
|
||||
if (normalizedRole === "toolresult" && extractChatMessagePreview(message)) {
|
||||
expandedStart = index;
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant" && hasRenderableNormalizedMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expandedStart;
|
||||
}
|
||||
|
||||
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
|
||||
let items: ChatItem[] = [];
|
||||
const historyRenderLimit = resolveHistoryRenderLimit(
|
||||
@@ -1118,20 +1268,32 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
(message) => !isAssistantHeartbeatAckForDisplay(message),
|
||||
);
|
||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||
const liftedCanvasSources = tools
|
||||
.map((tool) => extractChatMessagePreview(tool))
|
||||
.filter((entry) => Boolean(entry)) as Array<{
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
}>;
|
||||
const liftedCanvasSources = tools.flatMap((message, index) => {
|
||||
const source = extractChatMessagePreview(message);
|
||||
return source ? [{ ...source, message, index }] : [];
|
||||
});
|
||||
const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim());
|
||||
const persistedCanvasIdentities = new Set<string>();
|
||||
for (const message of history) {
|
||||
const source = extractChatMessagePreview(message);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const baseIdentity = canvasPreviewBaseIdentity(message, source);
|
||||
if (baseIdentity) {
|
||||
// fetchMcpAppView assigns a fresh viewId to every invocation. Matching the call and
|
||||
// view therefore identifies the same preview while still tolerating a reused call ID.
|
||||
persistedCanvasIdentities.add(baseIdentity);
|
||||
}
|
||||
}
|
||||
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit);
|
||||
const previewHistoryStart = expandHistoryStartForPersistedPreviews(history, historyStart);
|
||||
const hiddenHistoryCount = countVisibleHistoryMessages(
|
||||
history.slice(0, historyStart),
|
||||
history.slice(0, previewHistoryStart),
|
||||
props.showToolCalls,
|
||||
);
|
||||
const visibleHistoryCount = countVisibleHistoryMessages(
|
||||
history.slice(historyStart),
|
||||
history.slice(previewHistoryStart),
|
||||
props.showToolCalls,
|
||||
);
|
||||
if (hiddenHistoryCount > 0) {
|
||||
@@ -1145,7 +1307,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = historyStart; i < history.length; i++) {
|
||||
for (let i = previewHistoryStart; i < history.length; i++) {
|
||||
const msg = history[i];
|
||||
const normalized = safeNormalizeMessage(msg);
|
||||
if (!normalized) {
|
||||
@@ -1171,7 +1333,23 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
|
||||
const isToolResult = normalized.role.toLowerCase() === "toolresult";
|
||||
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
|
||||
const renderPersistedPreview =
|
||||
persistedCanvasSource != null &&
|
||||
(!searchFiltering || turnHasMatchingAssistant(history, i, props.searchQuery ?? ""));
|
||||
if (persistedCanvasSource && renderPersistedPreview) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: `${messageKey(msg, i)}:canvas`,
|
||||
message: createCanvasAssistantMessage(
|
||||
persistedCanvasSource,
|
||||
persistedCanvasSource.timestamp ?? transcriptPositionTimestamp(history, i),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.showToolCalls && isToolResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1190,13 +1368,20 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
});
|
||||
}
|
||||
const queuedSends = Array.isArray(props.queue) ? props.queue : [];
|
||||
for (const queued of queuedSends) {
|
||||
const activeRunQueuedSends = queuedSends.filter((queued) => queued.sendState === "waiting-model");
|
||||
const futureQueuedSends = queuedSends.filter((queued) => !activeRunQueuedSends.includes(queued));
|
||||
const futureQueuedTimestamp = futureQueuedSends.reduce<number | null>(
|
||||
(earliest, queued) =>
|
||||
earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt),
|
||||
null,
|
||||
);
|
||||
const appendQueuedSend = (queued: ChatQueueItem) => {
|
||||
if (!shouldRenderQueuedSendInThread(queued)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const message = queuedSendThreadMessage(queued);
|
||||
if (!message) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const searchQuery = props.searchQuery ?? "";
|
||||
if (
|
||||
@@ -1204,17 +1389,46 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
searchQuery.trim() &&
|
||||
!messageMatchesSearchQuery(message, searchQuery)
|
||||
) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: `pending-send:${queued.id}`,
|
||||
message,
|
||||
});
|
||||
};
|
||||
for (const queued of activeRunQueuedSends) {
|
||||
appendQueuedSend(queued);
|
||||
}
|
||||
for (const liftedCanvasSource of liftedCanvasSources) {
|
||||
const baseIdentity = canvasPreviewBaseIdentity(liftedCanvasSource.message, liftedCanvasSource);
|
||||
if (baseIdentity && persistedCanvasIdentities.has(baseIdentity)) {
|
||||
continue;
|
||||
}
|
||||
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
|
||||
if (assistantIndex == null) {
|
||||
if (searchFiltering) {
|
||||
continue;
|
||||
}
|
||||
const insertionIndex = findCanvasInsertionIndex(items, liftedCanvasSource.timestamp);
|
||||
const nextItem = items[insertionIndex];
|
||||
const nextTimestamp =
|
||||
nextItem?.kind === "message" ? rawMessageTimestamp(nextItem.message) : null;
|
||||
const boundaryTimestamp =
|
||||
nextTimestamp == null
|
||||
? futureQueuedTimestamp
|
||||
: futureQueuedTimestamp == null
|
||||
? nextTimestamp
|
||||
: Math.min(nextTimestamp, futureQueuedTimestamp);
|
||||
const timestamp =
|
||||
liftedCanvasSource.timestamp != null && boundaryTimestamp != null
|
||||
? Math.min(liftedCanvasSource.timestamp, boundaryTimestamp)
|
||||
: liftedCanvasSource.timestamp;
|
||||
items.splice(insertionIndex, 0, {
|
||||
kind: "message",
|
||||
key: `${messageKey(liftedCanvasSource.message, liftedCanvasSource.index + history.length)}:canvas`,
|
||||
message: createCanvasAssistantMessage(liftedCanvasSource, timestamp),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const item = items[assistantIndex];
|
||||
@@ -1230,6 +1444,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
),
|
||||
};
|
||||
}
|
||||
for (const queued of futureQueuedSends) {
|
||||
appendQueuedSend(queued);
|
||||
}
|
||||
items = items.filter(
|
||||
(item) => item.kind !== "message" || hasRenderableNormalizedMessage(item.message),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user