Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/web/src/components/settings/ExperimentsSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
} from "./settingsLayout";
// T3-CUSTOM(expbkt3): native plan review (moved here from the removed Beta panel).
import { searchableSetting } from "./settingsSearch";
// T3-CUSTOM(expbkt3): Agent views are runtime-disabled after URL frames proved untruthful.
import { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "../../fork/agentUiRuntime";

export function ExperimentsSettingsPanel() {
const phaseGroupedSidebarEnabled = useClientSettings(
Expand Down Expand Up @@ -55,10 +57,11 @@ export function ExperimentsSettingsPanel() {
{/* T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat. */}
<SettingsRow
{...searchableSetting("agent-ui-surfaces")}
description="Let agents render interactive views inline in the chat: charts, diagrams, tables, forms and other small HTML documents, shown in a sandboxed box where the tool call happened. Agents reach this through the t3_show_ui tool. While off, those calls stay ordinary collapsed tool rows."
description="Temporarily unavailable: framed URL apps can substitute origin-local state for the requested live view. t3_show_ui calls stay ordinary collapsed tool rows until Agent views can render truthfully."
control={
<Switch
checked={agentUiSurfacesEnabled}
checked={AGENT_UI_SURFACES_RUNTIME_ENABLED && agentUiSurfacesEnabled}
disabled={!AGENT_UI_SURFACES_RUNTIME_ENABLED}
onCheckedChange={(checked) =>
updateSettings({ agentUiSurfacesEnabled: Boolean(checked) })
}
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/fork/agentUiRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* T3-CUSTOM(expbkt3): emergency gate for agent-rendered chat surfaces.
*
* URL targets can intentionally disable live behavior whenever they run in an
* iframe, then fall back to origin-local state that is unrelated to the URL the
* agent supplied. Until T3 has a truthful generic URL-surface contract, no
* persisted client preference may turn Agent views back on.
*/
export const AGENT_UI_SURFACES_RUNTIME_ENABLED = false;
211 changes: 74 additions & 137 deletions apps/web/src/fork/agentUiSurface.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,45 @@ import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const queryStates = vi.hoisted(
() =>
new Map<
string,
{
data?: { render: Record<string, unknown> };
isPending: boolean;
error?: string;
}
>(),
);
const testState = vi.hoisted(() => ({
queryCalls: [] as string[],
queries: new Map<
string,
{
data?: { render: Record<string, unknown> };
isPending: boolean;
error?: string;
}
>(),
}));

vi.mock("../hooks/useSettings", () => ({
useClientSettings: (selector: (settings: { agentUiSurfacesEnabled: boolean }) => unknown) =>
selector({ agentUiSurfacesEnabled: true }),
}));
vi.mock("../state/agentUi", () => ({
agentUiEnvironment: {
render: ({ input }: { input: { renderId: string } }) => input,
render: ({ input }: { input: { renderId: string } }) => {
testState.queryCalls.push(input.renderId);
return input;
},
},
}));
vi.mock("../state/query", () => ({
useEnvironmentQuery: ({ renderId }: { renderId: string }) =>
queryStates.get(renderId) ?? { isPending: true },
testState.queries.get(renderId) ?? { isPending: true },
}));

import { AgentUiRenderFrame, AgentUiUrlFrame } from "./agentUiSurface";
import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator";
import { useAgentUiExpandedStore } from "../agentUiExpandedStore";
import { AgentUiExpandedSurface, AgentUiRenderFrame, AgentUiSurfaceRow } from "./agentUiSurface";

const THREAD_REF = {
environmentId: EnvironmentId.make("environment-fixture"),
threadId: ThreadId.make("thread-fixture"),
} as const;
const FIRST_URL = "https://fixture.example.test/board#room=alpha,safe-key-a";
const SECOND_URL = "https://fixture.example.test/board#room=beta,safe-key-b";
const mutations: string[] = [];

// ReactDOM needs a host, but this focused lifecycle suite intentionally has no
// browser dependency. The host records iframe attachment order so a switch can
// prove that the old browsing context disconnected before the new one mounted.
class TestNode {
parentNode: TestNode | null = null;
childNodes: TestNode[] = [];
Expand Down Expand Up @@ -70,7 +73,6 @@ class TestNode {
appendChild(child: TestNode) {
child.parentNode = this;
this.childNodes.push(child);
if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`);
return child;
}

Expand All @@ -79,12 +81,10 @@ class TestNode {
const index = this.childNodes.indexOf(before);
child.parentNode = this;
this.childNodes.splice(index, 0, child);
if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`);
return child;
}

removeChild(child: TestNode) {
if (child.tagName === "IFRAME") mutations.push(`detach:${child.getAttribute("src")}`);
this.childNodes.splice(this.childNodes.indexOf(child), 1);
child.parentNode = null;
return child;
Expand Down Expand Up @@ -120,7 +120,6 @@ function installTestDom() {
const document = new TestNode("#document", null, 9);
const window = {
document,
location: { origin: "https://t3.example.test" },
HTMLIFrameElement: TestNode,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
Expand All @@ -134,45 +133,39 @@ function installTestDom() {
return document;
}

function urlFrame(renderId: string, url: string, createdAt: string, placement = "inline") {
return (
<AgentUiUrlFrame
render={{ renderId, title: renderId, url, createdAt }}
threadRef={THREAD_REF}
placement={placement as "inline" | "expanded"}
/>
);
}

function iframeNodes(root: TestNode): TestNode[] {
return root.childNodes.flatMap((child) => [
...(child.tagName === "IFRAME" ? [child] : []),
...iframeNodes(child),
]);
}

function renderedText(root: TestNode): string {
return [
...(root.childNodes.length === 0 && root.nodeValue !== null ? [root.nodeValue] : []),
...root.childNodes.map(renderedText),
].join("");
}

async function render(root: { render: (children: ReactNode) => void }, children: ReactNode) {
flushSync(() => root.render(children));
await Promise.resolve();
flushSync(() => undefined);
}

describe("AgentUiUrlFrame DOM lifecycle", () => {
describe("Agent view runtime mitigation", () => {
beforeEach(() => {
mutations.length = 0;
queryStates.clear();
useAgentUiUrlFrameCoordinator.getState().reset();
testState.queryCalls.length = 0;
testState.queries.clear();
useAgentUiExpandedStore.getState().collapse();
});

afterEach(async () => {
// React's development scheduler posts an Immediate after a root commits.
// Let it drain while the fake window still exists so parallel CI cannot
// observe a callback after Vitest restores the Node globals.
await new Promise<void>((resolve) => setImmediate(resolve));
vi.unstubAllGlobals();
});

it("keeps exact same-origin URLs distinct and replaces the iframe on A to B to A", async () => {
it("ignores a persisted enabled preference and leaves only the ordinary tool row", async () => {
const document = installTestDom();
const { createRoot } = await import("react-dom/client");
const container = document.createElement("div");
Expand All @@ -181,128 +174,72 @@ describe("AgentUiUrlFrame DOM lifecycle", () => {
try {
await render(
root,
<>
{urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z")}
{urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")}
</>,
<AgentUiSurfaceRow
threadRef={THREAD_REF}
surface={{ renderId: "aui_alpha", kind: "url", height: 360 }}
>
<span>ordinary tool row</span>
</AgentUiSurfaceRow>,
);

const [betaNode] = iframeNodes(container);
expect(betaNode?.getAttribute("src")).toBe(SECOND_URL);
expect(betaNode?.getAttribute("credentialless")).toBe("");
expect(betaNode?.getAttribute("sandbox")).toContain("allow-same-origin");

flushSync(() =>
useAgentUiUrlFrameCoordinator
.getState()
.activate("inline:environment-fixture:thread-fixture:aui_alpha"),
);
await Promise.resolve();
flushSync(() => undefined);
const [alphaNode] = iframeNodes(container);
expect(betaNode?.parentNode).toBeNull();
expect(alphaNode).not.toBe(betaNode);
expect(alphaNode?.getAttribute("src")).toBe(FIRST_URL);

flushSync(() =>
useAgentUiUrlFrameCoordinator
.getState()
.activate("inline:environment-fixture:thread-fixture:aui_beta"),
);
await Promise.resolve();
flushSync(() => undefined);
const [nextBetaNode] = iframeNodes(container);
expect(alphaNode?.parentNode).toBeNull();
expect(nextBetaNode).not.toBe(alphaNode);
expect(nextBetaNode).not.toBe(betaNode);
expect(nextBetaNode?.getAttribute("src")).toBe(SECOND_URL);
expect(mutations).toEqual([
`attach:${SECOND_URL}`,
`detach:${SECOND_URL}`,
`attach:${FIRST_URL}`,
`detach:${FIRST_URL}`,
`attach:${SECOND_URL}`,
]);
expect(renderedText(container)).toBe("ordinary tool row");
expect(iframeNodes(container)).toHaveLength(0);
expect(testState.queryCalls).toEqual([]);
} finally {
flushSync(() => root.unmount());
}
});

it("gives an expanded frame exclusive priority and restores inline after it closes", async () => {
it("keeps an already-populated expanded store closed", async () => {
const document = installTestDom();
const { createRoot } = await import("react-dom/client");
const container = document.createElement("div");
const root = createRoot(container as unknown as Element);
useAgentUiExpandedStore.getState().expand({ threadRef: THREAD_REF, renderId: "aui_alpha" });

try {
await render(
root,
<>
{urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")}
{urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z", "expanded")}
</>,
);
const [expandedNode] = iframeNodes(container);
expect(iframeNodes(container)).toHaveLength(1);
expect(expandedNode?.getAttribute("src")).toBe(FIRST_URL);

await render(root, urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z"));
const [inlineNode] = iframeNodes(container);
expect(expandedNode?.parentNode).toBeNull();
expect(iframeNodes(container)).toHaveLength(1);
expect(inlineNode).not.toBe(expandedNode);
expect(inlineNode?.getAttribute("src")).toBe(SECOND_URL);
await render(root, <AgentUiExpandedSurface />);
expect(renderedText(container)).toBe("");
expect(iframeNodes(container)).toHaveLength(0);
expect(testState.queryCalls).toEqual([]);
} finally {
flushSync(() => root.unmount());
}
});

it("disconnects an expanded iframe while the replacement query is pending", async () => {
it("never mounts either exact same-origin room URL if the inner frame is called directly", async () => {
const document = installTestDom();
const { createRoot } = await import("react-dom/client");
const container = document.createElement("div");
const root = createRoot(container as unknown as Element);
const firstRender = {
renderId: "aui_alpha",
title: "First",
kind: "url",
url: FIRST_URL,
createdAt: "2026-08-29T10:00:00.000Z",
};
const secondRender = {
renderId: "aui_beta",
title: "Second",
kind: "url",
url: SECOND_URL,
createdAt: "2026-08-29T10:01:00.000Z",
};
queryStates.set(firstRender.renderId, { data: { render: firstRender }, isPending: false });
queryStates.set(secondRender.renderId, { isPending: true });

const expandedFrame = (renderId: string) => (
<AgentUiRenderFrame
key={renderId}
threadRef={THREAD_REF}
renderId={renderId}
placement="expanded"
onTitle={() => undefined}
/>
);
for (const [renderId, url] of [
["aui_alpha", FIRST_URL],
["aui_beta", SECOND_URL],
] as const) {
testState.queries.set(renderId, {
data: {
render: {
renderId,
title: renderId,
kind: "url",
url,
createdAt: "2026-08-29T10:00:00.000Z",
},
},
isPending: false,
});
}

try {
await render(root, expandedFrame(firstRender.renderId));
const [firstNode] = iframeNodes(container);
expect(firstNode?.getAttribute("src")).toBe(FIRST_URL);

await render(root, expandedFrame(secondRender.renderId));
expect(firstNode?.parentNode).toBeNull();
await render(root, <AgentUiRenderFrame threadRef={THREAD_REF} renderId="aui_alpha" />);
expect(iframeNodes(container)).toHaveLength(0);
expect(renderedText(container)).toContain("URL Agent views are temporarily disabled");

queryStates.set(secondRender.renderId, { data: { render: secondRender }, isPending: false });
await render(root, expandedFrame(secondRender.renderId));
const [secondNode] = iframeNodes(container);
expect(secondNode).not.toBe(firstNode);
expect(secondNode?.getAttribute("src")).toBe(SECOND_URL);
await render(root, <AgentUiRenderFrame threadRef={THREAD_REF} renderId="aui_beta" />);
expect(iframeNodes(container)).toHaveLength(0);
expect(renderedText(container)).toContain("URL Agent views are temporarily disabled");
expect(FIRST_URL).not.toBe(SECOND_URL);
expect(testState.queryCalls).toEqual(["aui_alpha", "aui_beta"]);
} finally {
flushSync(() => root.unmount());
}
Expand Down
Loading
Loading