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
14 changes: 12 additions & 2 deletions apps/server/src/mcp/toolkits/t3team/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,11 +375,21 @@ export const T3TeamShowWidgetTool = Tool.make("t3_show_widget", {
description:
"Short snake_case identifier for this widget (e.g. 'q4_revenue_chart'). Used as the artifact name.",
}),
intent: Schema.optional(Schema.String).annotate({
description:
"Preferred default: describe what to show. A builder subagent authors the widget (theme/icon contract) and html output goes through the upstream HTML render shim. Omit when passing widget_code.",
}),
// The full authoring contract rides the property annotation: it is the text the model reads
// while writing the widget body, and it is the single source of truth for the theme-token,
// icon-sprite, layout and CSP rules (never hard-code light or dark palette colors).
widget_code: Schema.String.annotate({ description: T3TEAM_WIDGET_AUTHORING_GUIDANCE }),
format: Schema.optional(Schema.Literals(["html", "svg"])),
// Optional when `intent` is provided; required to skip the builder (deterministic workflows).
widget_code: Schema.optional(Schema.String).annotate({
description: T3TEAM_WIDGET_AUTHORING_GUIDANCE,
}),
format: Schema.optional(Schema.Literals(["html", "svg"])).annotate({
description:
"html is shimmed onto upstream html_render storage/theme; svg stays on the widget tier.",
}),
loading_messages: Schema.optional(Schema.Array(Schema.String)),
capabilities: Schema.optional(
Schema.Struct({ tools: Schema.optional(Schema.Array(Schema.String)) }),
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ import { T3TeamWorkflowEngineRegistryLive } from "./t3team-workflowEngineRegistr
import { T3TeamWorkflowSchedulerLive } from "./t3team-workflowScheduler.ts";
import { T3TeamWorkflowSchedulerSweepLive } from "./t3team-workflowSchedulerSweepLive.ts";
import { T3TeamToolBrokerLive } from "./t3team-toolBrokerLive.ts";
import * as HtmlRender from "./htmlRender/HtmlRender.ts";
import * as PreviewBrowser from "./htmlRender/PreviewBrowser.ts";
import { T3TeamV2FoundationLive } from "./t3team-v2/t3team-v2FoundationLive.ts";
import * as T3TeamWorkflowHost from "./t3team-workflowHost.ts";
import { T3TeamChildThreadMetadataLive } from "./t3team-childThreadMetadata.ts";
Expand Down Expand Up @@ -631,6 +633,10 @@ const T3TeamToolBrokerLayerLive = T3TeamToolBrokerLive.pipe(
Layer.provide(WorkflowRunRepositoryLive),
Layer.provide(T3TeamChildThreadMetadataLive),
Layer.provide(layerPullRequestService),
// html format on t3_show_widget shims onto upstream HtmlRender.publish (Phil 2026-10-06).
// Same HtmlRender + PreviewBrowser pair the MCP html_render toolkit uses.
Layer.provide(HtmlRender.layer),
Layer.provide(PreviewBrowser.layer),
);

// Webhook URLs go through the relay only when the managed tunnel it forwards
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/t3team-toolBrokerWidgetShow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as SqlClient from "effect/sql/SqlClient";

import * as HtmlRender from "./htmlRender/HtmlRender.ts";
import { WorkspacePaths } from "./workspace/WorkspacePaths.ts";
import type { T3TeamToolCallResult } from "./t3team-toolBroker.ts";
import { errorResult } from "./t3team-toolBrokerHelpers.ts";
Expand All @@ -26,6 +27,7 @@ export interface T3TeamWidgetShowRuntime {
readonly registry: T3TeamWidgetRegistryShape | undefined;
readonly artifacts: T3TeamThreadArtifactsStore["Service"] | undefined;
readonly persistenceContext: Context.Context<T3TeamWidgetPersistenceServices> | undefined;
readonly htmlRender: HtmlRender.HtmlRender["Service"] | undefined;
}

/** Capture the widget pipeline's optional services from the ambient layer context. */
Expand All @@ -36,6 +38,7 @@ const captureT3TeamWidgetShowRuntime = Effect.fnUntraced(function* () {
const path = Option.getOrUndefined(yield* Effect.serviceOption(Path.Path));
const sqlClient = Option.getOrUndefined(yield* Effect.serviceOption(SqlClient.SqlClient));
const workspacePaths = Option.getOrUndefined(yield* Effect.serviceOption(WorkspacePaths));
const htmlRender = Option.getOrUndefined(yield* Effect.serviceOption(HtmlRender.HtmlRender));

// The CAS artifact write needs all four services; persistence degrades to an inline-only
// widget when any is missing in this runtime.
Expand All @@ -49,7 +52,7 @@ const captureT3TeamWidgetShowRuntime = Effect.fnUntraced(function* () {
)
: undefined;

return { registry, artifacts, persistenceContext } satisfies T3TeamWidgetShowRuntime;
return { registry, artifacts, persistenceContext, htmlRender } satisfies T3TeamWidgetShowRuntime;
});

/** One-shot binder: capture the runtime once at layer build, then mint per-thread callbacks. */
Expand Down Expand Up @@ -89,6 +92,7 @@ export function makeT3TeamShowWidget<TLoadError>(input: {
registry,
recordArtifact: artifacts.upsert,
persistenceContext: input.runtime.persistenceContext,
htmlRender: input.runtime.htmlRender,
},
});
});
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/t3team-widgetBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import {
authorWidgetFromIntent,
shouldBypassBuilder,
T3TEAM_WIDGET_BUILDER_UNAVAILABLE,
} from "./t3team-widgetBuilder.ts";

describe("t3team-widgetBuilder", () => {
it("shouldBypassBuilder is true only when widgetCode is non-empty", () => {
assert.isTrue(shouldBypassBuilder({ widgetCode: "<div/>" }));
assert.isFalse(shouldBypassBuilder({ widgetCode: "" }));
});

it.effect("authorWidgetFromIntent fails without inventing HTML", () =>
Effect.gen(function* () {
const result = yield* authorWidgetFromIntent({
intent: "Show a chart",
title: "chart",
format: "html",
}).pipe(Effect.result);
assert.strictEqual(result._tag, "Failure");
if (result._tag === "Failure") {
assert.strictEqual(result.failure, T3TEAM_WIDGET_BUILDER_UNAVAILABLE);
assert.include(result.failure, "widget_code");
}
}),
);
});
45 changes: 45 additions & 0 deletions apps/server/src/t3team-widgetBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Intent → builder/authoring seam for `t3team.widget.show` / `thread.showWidget`
* (design intent PJ 2026-08-29, Epic 24 widget composition default).
*
* Flow: intent → builder subagent → widgetCode → format routing (html shim | widget tier).
* Raw `widget_code` bypasses this module entirely (required for deterministic workflow replay).
*
* The full builder loop (reuse-check → draft → typecheck → preview via HtmlRender → fix) is
* not wired to a model in this change-set; callers that pass only `intent` get a clear error
* until the subagent lands. Do not invent placeholder HTML here.
*/

import * as Effect from "effect/Effect";

import type { T3TeamWidgetFormat, T3TeamWidgetShowInput } from "./t3team-widgetShowCore.ts";

export const T3TEAM_WIDGET_BUILDER_UNAVAILABLE =
"Widget builder subagent is not connected yet. Pass widget_code to skip the builder " +
"(required for deterministic workflow replay), or retry once intent authoring is available.";

/** True when the caller supplied raw widget code and should skip the builder. */
export function shouldBypassBuilder(parsed: Pick<T3TeamWidgetShowInput, "widgetCode">): boolean {
return parsed.widgetCode.length > 0;
}

export interface AuthorWidgetFromIntentInput {
readonly intent: string;
readonly title: string;
readonly format: T3TeamWidgetFormat;
}

export interface AuthorWidgetFromIntentResult {
readonly widgetCode: string;
readonly format: T3TeamWidgetFormat;
}

/**
* Author widget markup from an intent. Until the builder subagent is wired, this always
* fails with {@link T3TEAM_WIDGET_BUILDER_UNAVAILABLE} — never invents HTML.
*/
export function authorWidgetFromIntent(
_input: AuthorWidgetFromIntentInput,
): Effect.Effect<AuthorWidgetFromIntentResult, string> {
return Effect.fail(T3TEAM_WIDGET_BUILDER_UNAVAILABLE);
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not advertise an intent path that always fails

Any caller following the new model-facing recommendation to provide only intent reaches this function, which unconditionally returns T3TEAM_WIDGET_BUILDER_UNAVAILABLE; consequently the newly preferred/default path can never display a widget. Either keep intent out of the MCP/catalog/workflow contracts until the builder exists, or implement the builder before directing agents to use it.

Useful? React with 👍 / 👎.

}
96 changes: 81 additions & 15 deletions apps/server/src/t3team-widgetShowCore.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
/**
* Pure input validation + attachment construction for the `t3team.widget.show` broker tool
* (Epic 24 ad-hoc widget tier). Effectful orchestration lives in `t3team-widgetShowTool.ts`.
*
* `format: "html"` is shimmed onto upstream HtmlRender storage (Phil 2026-10-06): the fragment
* stays on the attachment for back-compat; an optional `htmlRender` ref points at the published
* attachment clients render via HtmlRenderFrame. Other formats stay on the widget tier.
*/

import { PROJECT_STATE_DIR } from "@t3tools/project-context/t3teamProjectStateDir";
import type { T3TeamMessageWidgetAttachment } from "@t3tools/contracts";
import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";

import { resolveWidgetCapabilityPolicy } from "./t3team-widgetCapabilityPolicy.ts";

const T3TEAM_WIDGET_SHOW_TOOL_ID = "t3team.widget.show";
const T3TEAM_WIDGET_CODE_MAX_BYTES = 128 * 1024;
const TITLE_MAX_LENGTH = 64;
const INTENT_MAX_LENGTH = 8_000;
const LOADING_MESSAGES_MAX = 8;
const LOADING_MESSAGE_MAX_LENGTH = 200;
const TOOLS_MAX = 16;

/** CSP kept for widget html even when stored via HtmlRender (no remote network). */
export const T3TEAM_WIDGET_HTML_CSP =
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'";

/** Default frame height when shim-publishing via HtmlRender (clients refine via measured heights). */
export const T3TEAM_WIDGET_HTML_RENDER_DEFAULT_HEIGHT = 400;

export type T3TeamWidgetFormat = "html" | "svg" | "mdx" | "tsx";
const WIDGET_FORMATS: ReadonlySet<T3TeamWidgetFormat> = new Set(["html", "svg", "mdx", "tsx"]);
/** Formats the sandboxed-iframe pipeline renders today. mdx/tsx are reserved seams that will
Expand All @@ -24,7 +37,10 @@ const IMPLEMENTED_WIDGET_FORMATS: ReadonlySet<T3TeamWidgetFormat> = new Set(["ht
export interface T3TeamWidgetShowInput {
readonly title: string;
readonly format: T3TeamWidgetFormat;
/** Present when the caller bypassed the builder with raw code. Empty when intent-only. */
readonly widgetCode: string;
/** Preferred default input: what the caller wants to see. Builder authors widgetCode from it. */
readonly intent: string | undefined;
readonly loadingMessages: ReadonlyArray<string>;
/** Allowlist of broker tool ids the widget bridge may call. */
readonly tools: ReadonlyArray<string>;
Expand All @@ -43,12 +59,29 @@ function readStringArray(value: unknown, max: number): ReadonlyArray<string> | u
.slice(0, max);
}

/**
* Wrap a validated HTML fragment into a full document suitable for HtmlRender.publish,
* preserving the widget CSP (`connect-src 'none'`) — never route through publicProxy.
*/
export function wrapWidgetHtmlFragmentForHtmlRender(fragment: string): string {
return [
"<!DOCTYPE html>",
'<html><head><meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${T3TEAM_WIDGET_HTML_CSP}">`,
"</head><body>",
fragment,
"</body></html>",
].join("");
}

/** Validate raw tool arguments. Returns the parsed input or a human-readable error string. */
export function parseT3TeamWidgetShowInput(
toolArgs: unknown,
): T3TeamWidgetShowInput | { readonly error: string } {
if (!toolArgs || typeof toolArgs !== "object" || globalThis.Array.isArray(toolArgs)) {
return { error: "t3team.widget.show requires an object with title and widget_code." };
return {
error: "t3team.widget.show requires an object with title and intent or widget_code.",
};
}
const args = toolArgs as Record<string, unknown>;

Expand All @@ -65,22 +98,35 @@ export function parseT3TeamWidgetShowInput(
return { error: "title must contain at least one alphanumeric character." };
}

const rawIntent = typeof args.intent === "string" ? args.intent.trim() : "";
const intent =
rawIntent.length === 0
? undefined
: rawIntent.length > INTENT_MAX_LENGTH
? rawIntent.slice(0, INTENT_MAX_LENGTH)
: rawIntent;

const widgetCode = typeof args.widget_code === "string" ? args.widget_code.trim() : "";
if (widgetCode.length === 0) {
return { error: "widget_code is required and must be a non-empty string." };
}
// Cap in UTF-8 bytes (what actually gets persisted and shipped), not UTF-16 length.
if (new TextEncoder().encode(widgetCode).byteLength > T3TEAM_WIDGET_CODE_MAX_BYTES) {
return {
error: `widget_code exceeds the ${T3TEAM_WIDGET_CODE_MAX_BYTES / 1024} KB limit.`,
};
}
if (FORBIDDEN_DOCUMENT_MARKUP.test(widgetCode)) {
if (widgetCode.length === 0 && intent === undefined) {
return {
error:
"widget_code must be a fragment: raw SVG or HTML without <!DOCTYPE>, <html>, <head>, or <body> tags.",
"Provide intent (preferred: describe what to show; a builder authors the widget) or widget_code (raw bypass, required for deterministic workflow replay).",
};
}
if (widgetCode.length > 0) {
// Cap in UTF-8 bytes (what actually gets persisted and shipped), not UTF-16 length.
if (new TextEncoder().encode(widgetCode).byteLength > T3TEAM_WIDGET_CODE_MAX_BYTES) {
return {
error: `widget_code exceeds the ${T3TEAM_WIDGET_CODE_MAX_BYTES / 1024} KB limit.`,
};
}
if (FORBIDDEN_DOCUMENT_MARKUP.test(widgetCode)) {
return {
error:
"widget_code must be a fragment: raw SVG or HTML without <!DOCTYPE>, <html>, <head>, or <body> tags.",
};
}
}

let format: T3TeamWidgetFormat | undefined;
if (args.format !== undefined) {
Expand All @@ -90,7 +136,9 @@ export function parseT3TeamWidgetShowInput(
format = args.format as T3TeamWidgetFormat;
}
// Mirror the Claude desktop convention: auto-detect svg vs html from the code itself.
const resolvedFormat = format ?? (widgetCode.startsWith("<svg") ? "svg" : "html");
// Intent-only defaults to html (builder / shim path).
const resolvedFormat =
format ?? (widgetCode.length > 0 ? (widgetCode.startsWith("<svg") ? "svg" : "html") : "html");
if (!IMPLEMENTED_WIDGET_FORMATS.has(resolvedFormat)) {
const target =
resolvedFormat === "tsx"
Expand Down Expand Up @@ -143,7 +191,14 @@ export function parseT3TeamWidgetShowInput(
const policy = resolveWidgetCapabilityPolicy(resolvedFormat);
const effectiveTools = policy.callToolAllowed ? tools : [];

return { title, format: resolvedFormat, widgetCode, loadingMessages, tools: effectiveTools };
return {
title,
format: resolvedFormat,
widgetCode,
intent,
loadingMessages,
tools: effectiveTools,
};
}

export function buildT3TeamWidgetArtifactRelativePath(input: {
Expand All @@ -157,15 +212,26 @@ export function buildT3TeamWidgetAttachment(input: {
readonly widgetId: string;
readonly parsed: T3TeamWidgetShowInput;
readonly artifactRelativePath: string | undefined;
readonly htmlRender?: HtmlRenderReference | undefined;
}): T3TeamMessageWidgetAttachment {
const { widgetId, parsed, artifactRelativePath } = input;
const { widgetId, parsed, artifactRelativePath, htmlRender } = input;
return {
kind: "widget",
widget: {
widgetId,
title: parsed.title,
format: parsed.format,
html: parsed.widgetCode,
...(htmlRender
? {
htmlRender: {
attachmentId: htmlRender.attachmentId,
title: htmlRender.title,
height: htmlRender.height,
...(htmlRender.heights === undefined ? {} : { heights: [...htmlRender.heights] }),
},
}
: {}),
...(artifactRelativePath
? {
artifact: {
Expand Down
Loading
Loading