Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const clientSettings: ClientSettings = {
// T3-CUSTOM(expbkt3): plan mode is available by default in the fork.
planModeAvailable: true,
nativePlanReviewEnabled: true,
agentUiSurfacesEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
providerRateLimitsEnabled: true,
Expand Down
138 changes: 138 additions & 0 deletions apps/server/src/agentui/AgentUiService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* T3-CUSTOM(expbkt3): coverage for agent-rendered UI surfaces.
*
* The validation here is the security boundary an agent's input crosses, so the
* tests pin the rejections as tightly as the happy path: an agent that can
* choose the framed URL scheme, or push an unbounded document into the database,
* is a different feature from the one we shipped.
*/
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AGENT_UI_MAX_HTML_CHARS, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { MigrationsLive } from "../persistence/Migrations.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as AgentUiRenders from "../persistence/AgentUiRenders.ts";
import * as AgentUiServiceModule from "./AgentUiService.ts";
import { AgentUiService } from "./AgentUiService.ts";

const threadId = ThreadId.make("thread-agent-ui");
const otherThreadId = ThreadId.make("thread-agent-ui-other");

const layer = AgentUiServiceModule.layer.pipe(
Layer.provide(AgentUiRenders.layer),
Layer.provide(MigrationsLive),
Layer.provide(SqlitePersistenceMemory),
Layer.provide(NodeServices.layer),
);

const withService = <A, E>(
body: (service: AgentUiService["Service"]) => Effect.Effect<A, E, never>,
) =>
Effect.gen(function* () {
const service = yield* AgentUiService;
return yield* body(service);
}).pipe(Effect.provide(layer));

describe("AgentUiService", () => {
it.effect("stores an inline document and reads it back by handle", () =>
withService((service) =>
Effect.gen(function* () {
const handle = yield* service.show({
threadId,
title: " Latency ",
html: "<p>hello</p>",
});
expect(handle.kind).toBe("html");
expect(handle.renderId.startsWith("aui_")).toBe(true);

const render = yield* service.getRender({ threadId, renderId: handle.renderId });
expect(render?.html).toBe("<p>hello</p>");
expect(render?.title).toBe("Latency");
expect(render?.url).toBeNull();
}),
),
);

it.effect("scopes a render to its own thread", () =>
withService((service) =>
Effect.gen(function* () {
const handle = yield* service.show({ threadId, title: "Chart", html: "<b>x</b>" });
const leaked = yield* service.getRender({
threadId: otherThreadId,
renderId: handle.renderId,
});
expect(leaked).toBeNull();
}),
),
);

it.effect("clamps the height into the embeddable range", () =>
withService((service) =>
Effect.gen(function* () {
const tall = yield* service.show({ threadId, title: "t", html: "<p/>", height: 5_000 });
const short = yield* service.show({ threadId, title: "t", html: "<p/>", height: 1 });
const absent = yield* service.show({ threadId, title: "t", html: "<p/>" });
expect(tall.height).toBe(900);
expect(short.height).toBe(120);
expect(absent.height).toBe(360);
}),
),
);

it.effect("accepts an https URL and rejects every other scheme", () =>
withService((service) =>
Effect.gen(function* () {
const ok = yield* service.show({ threadId, title: "Docs", url: "https://example.com/a" });
expect(ok.kind).toBe("url");

for (const url of [
"http://example.com",
"javascript:alert(1)",
"file:///etc/passwd",
"data:text/html,<script>alert(1)</script>",
"not a url",
]) {
const failure = yield* service.show({ threadId, title: "x", url }).pipe(Effect.flip);
expect(failure.message).toContain("https://");
}
}),
),
);

it.effect("requires exactly one of html or url", () =>
withService((service) =>
Effect.gen(function* () {
const neither = yield* service.show({ threadId, title: "x" }).pipe(Effect.flip);
expect(neither.message).toContain("Provide either");

const both = yield* service
.show({ threadId, title: "x", html: "<p/>", url: "https://example.com" })
.pipe(Effect.flip);
expect(both.message).toContain("only one");
}),
),
);

it.effect("refuses a document past the size cap", () =>
withService((service) =>
Effect.gen(function* () {
const failure = yield* service
.show({ threadId, title: "x", html: "a".repeat(AGENT_UI_MAX_HTML_CHARS + 1) })
.pipe(Effect.flip);
expect(failure.message).toContain("the limit is");
}),
),
);

it.effect("returns null for a render that was never stored", () =>
withService((service) =>
Effect.gen(function* () {
const missing = yield* service.getRender({ threadId, renderId: "aui_nope" });
expect(missing).toBeNull();
}),
),
);
});
153 changes: 153 additions & 0 deletions apps/server/src/agentui/AgentUiService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* T3-CUSTOM(expbkt3): agent-rendered UI surfaces shown inline in the chat.
*
* The `t3_show_ui` MCP tool records a render here and gets back a short handle;
* the chat later fetches the body by that handle. Keeping the two halves in one
* fork-owned service means the validation an agent's input goes through — size
* caps, height clamping, URL scheme — is the same validation the reader relies
* on, and none of it lives in an upstream file.
*/
import {
AGENT_UI_DEFAULT_HEIGHT,
AGENT_UI_MAX_HEIGHT,
AGENT_UI_MAX_HTML_CHARS,
AGENT_UI_MIN_HEIGHT,
AgentUiError,
type AgentUiRenderHandle,
type AgentUiRenderRecord,
type ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { AgentUiRepository } from "../persistence/AgentUiRenders.ts";

export interface ShowUiInput {
readonly threadId: ThreadId;
readonly title: string;
readonly html?: string | undefined;
readonly url?: string | undefined;
readonly height?: number | undefined;
}

export interface AgentUiServiceShape {
readonly show: (input: ShowUiInput) => Effect.Effect<AgentUiRenderHandle, AgentUiError>;
readonly getRender: (input: {
readonly threadId: ThreadId;
readonly renderId: string;
}) => Effect.Effect<AgentUiRenderRecord | null, AgentUiError>;
}

export class AgentUiService extends Context.Service<AgentUiService, AgentUiServiceShape>()(
"t3/agentui/AgentUiService",
) {}

function clampHeight(height: number | undefined): number {
if (height === undefined || !Number.isFinite(height)) return AGENT_UI_DEFAULT_HEIGHT;
return Math.max(AGENT_UI_MIN_HEIGHT, Math.min(Math.round(height), AGENT_UI_MAX_HEIGHT));
}

/**
* Only https is embeddable. http would be blocked as mixed content on every
* hosted deployment, and the non-network schemes are how a framed document
* would reach back at the app.
*/
function normalizeUrl(raw: string): string | null {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}
return parsed.protocol === "https:" ? parsed.toString() : null;
}

export const make = Effect.gen(function* () {
const repository = yield* AgentUiRepository;
const crypto = yield* Crypto.Crypto;

// A failing CSPRNG is a defect, not something a caller can recover from.
const uuid = crypto.randomUUIDv4.pipe(Effect.orDie);

const show: AgentUiServiceShape["show"] = (input) =>
Effect.gen(function* () {
const title = input.title.trim() || "Agent view";
const html = input.html?.trim() ?? "";
const rawUrl = input.url?.trim() ?? "";

if (html.length === 0 && rawUrl.length === 0) {
return yield* new AgentUiError({
operation: "show",
message: "Provide either `html` or `url`.",
});
}
if (html.length > 0 && rawUrl.length > 0) {
return yield* new AgentUiError({
operation: "show",
message: "Provide only one of `html` or `url`, not both.",
});
}
if (html.length > AGENT_UI_MAX_HTML_CHARS) {
return yield* new AgentUiError({
operation: "show",
message: `The document is ${html.length} characters; the limit is ${AGENT_UI_MAX_HTML_CHARS}. Render a smaller view, or link to a URL.`,
});
}

const url = rawUrl.length > 0 ? normalizeUrl(rawUrl) : null;
if (rawUrl.length > 0 && url === null) {
return yield* new AgentUiError({
operation: "show",
message: "`url` must be an absolute https:// URL.",
});
}

const kind = url === null ? ("html" as const) : ("url" as const);
const height = clampHeight(input.height);
const renderId = `aui_${(yield* uuid).replaceAll("-", "").slice(0, 20)}`;
const createdAt = yield* Effect.map(DateTime.now, DateTime.formatIso);

yield* repository
.insertRender({
renderId,
threadId: input.threadId,
title,
kind,
html: kind === "html" ? html : null,
url,
height,
createdAt,
})
.pipe(
Effect.mapError(
(cause) =>
new AgentUiError({
operation: "show",
message: `Could not store the view: ${String(cause)}`,
}),
),
);

return { renderId, kind, height, title } satisfies AgentUiRenderHandle;
});

const getRender: AgentUiServiceShape["getRender"] = (input) =>
repository.getRender(input).pipe(
Effect.map(Option.getOrNull),
Effect.mapError(
(cause) =>
new AgentUiError({
operation: "get-render",
message: `Could not read the view: ${String(cause)}`,
}),
),
);

return AgentUiService.of({ show, getRender });
});

export const layer = Layer.effect(AgentUiService, make);
3 changes: 3 additions & 0 deletions apps/server/src/auth/rpcForkScopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,7 @@ export const FORK_RPC_REQUIRED_SCOPES = {
[WS_FORK_METHODS.sessionArchiveBackfill]: AuthOrchestrationOperateScope,
// T3-CUSTOM(expbkt3): context handoff renders a string and writes nothing.
[WS_FORK_METHODS.threadContextExport]: AuthOrchestrationReadScope,
// T3-CUSTOM(expbkt3): agent-rendered UI surfaces. A read of thread content,
// and the handler additionally gates on per-thread access.
[WS_FORK_METHODS.agentUiGetRender]: AuthOrchestrationReadScope,
} as const;
31 changes: 31 additions & 0 deletions apps/server/src/mcp/toolkits/control/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { ProjectionSnapshotQuery } from "../../../orchestration/Services/Project
// T3-CUSTOM(expbkt3): bounded catch-up detail for t3_list_sessions.
import type { ProjectionSessionListDetail } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { PlannotatorManager } from "../../../plannotator/PlannotatorManager.ts";
// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
import { AgentUiService } from "../../../agentui/AgentUiService.ts";
import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts";
import { redactServerSettingsForClient, ServerSettingsService } from "../../../serverSettings.ts";
import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts";
Expand Down Expand Up @@ -1363,6 +1365,35 @@ const handlers = {
},
),

// T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat.
//
// Always renders into the caller's own session: the box appears where the tool
// call appears, so there is no cross-session target to authorize.
t3_show_ui: Effect.fn("T3ControlToolkit.showUi")(function* (input) {
const operation = "show-ui";
const scope = yield* requireCapability(operation, "t3.read");
const agentUi = yield* AgentUiService;
const handle = yield* agentUi
.show({
threadId: scope.threadId,
title: input.title,
html: input.html,
url: input.url,
height: input.height,
})
.pipe(mapControlError(operation));
// Small and single-line on purpose: activity projection summarizes an MCP
// result to one short line, and this handle has to survive that trip to
// reach the chat client.
return {
t3UiRender: true,
renderId: handle.renderId,
kind: handle.kind,
height: handle.height,
};
}),
// T3-CUSTOM(expbkt3): END

t3_dispatch_command: Effect.fn("T3ControlToolkit.dispatchCommand")(function* (input) {
const operation = "dispatch-command";
yield* requireExternalOperator(operation);
Expand Down
Loading
Loading