Skip to content
Closed
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: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ jobs:
- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

- name: Install Chromium for visualization security tests
working-directory: apps/web
run: vp exec playwright install chromium --only-shell --with-deps

- name: Test HTML visualization sandbox
run: vp run --filter @t3tools/web test:html-visualization

# apps/server sets `fileParallelism: false`, so its 239 files run strictly
# one at a time. Sharding spreads them over separate runners instead of
# separate workers, so no two server test files ever share a machine and the
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc";
import * as EffectCodexSchema from "effect-codex-app-server/schema";

import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";
import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
import {
buildTurnStartParams,
Expand Down Expand Up @@ -872,6 +873,47 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
for (const resumeThreadId of [undefined, "saved-thread"]) {
it.effect(
`delivers runtime instructions while preserving custom instructions on ${resumeThreadId ? "resume" : "start"}`,
() =>
Effect.gen(function* () {
const calls: Array<{ method: string; developerInstructions: string | null | undefined }> =
[];
const response = makeThreadOpenResponse("opened-thread");
yield* openCodexThread({
client: {
request: (method, payload) => {
calls.push({ method, developerInstructions: payload.developerInstructions });
return Effect.succeed(response);
},
raw: {
request: (method, payload) => {
calls.push({ method, developerInstructions: payload.developerInstructions });
return Effect.succeed(response);
},
},
},
configuredDeveloperInstructions: "Keep the user's existing coding conventions.",
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: undefined,
serviceTier: undefined,
resumeThreadId,
});
NodeAssert.equal(calls.length, 1);
NodeAssert.equal(calls[0]?.method, resumeThreadId ? "thread/resume" : "thread/start");
NodeAssert.equal(
calls[0]?.developerInstructions,
`Keep the user's existing coding conventions.\n\n${buildRuntimeInstructions({ harness: "Codex" })}`,
);
NodeAssert.match(calls[0]?.developerInstructions ?? "", /<html_visualizations>/);
NodeAssert.match(calls[0]?.developerInstructions ?? "", /t3-html/);
}),
);
}

it.effect("resumes metadata when historical turns contain unknown error values", () =>
Effect.gen(function* () {
const response = makeThreadOpenResponse("saved-thread");
Expand Down Expand Up @@ -927,6 +969,7 @@ describe("openCodexThread", () => {
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
excludeTurns: true,
developerInstructions: buildRuntimeInstructions({ harness: "Codex" }),
},
},
]);
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
buildCodexDeveloperInstructions,
type T3CodeToolAvailability,
} from "../CodexDeveloperInstructions.ts";
import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);

const PROVIDER = ProviderDriverKind.make("codex");
Expand Down Expand Up @@ -543,6 +544,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): {
}

function buildThreadStartParams(input: {
readonly configuredDeveloperInstructions?: string | null | undefined;
readonly cwd: string;
readonly runtimeMode: RuntimeMode;
readonly model: string | undefined;
Expand All @@ -551,6 +553,13 @@ function buildThreadStartParams(input: {
const config = runtimeModeToThreadConfig(input.runtimeMode);
return {
cwd: input.cwd,
// Collaboration-mode instructions can be persisted without reaching the model.
developerInstructions: [
input.configuredDeveloperInstructions,
buildRuntimeInstructions({ harness: "Codex" }),
]
.filter(Boolean)
.join("\n\n"),
approvalPolicy: config.approvalPolicy,
sandbox: config.sandbox,
approvalsReviewer: config.approvalsReviewer,
Expand Down Expand Up @@ -717,6 +726,7 @@ interface CodexThreadOpenClient {
}

export const openCodexThread = (input: {
readonly configuredDeveloperInstructions?: string | null | undefined;
readonly client: CodexThreadOpenClient;
readonly threadId: ThreadId;
readonly runtimeMode: RuntimeMode;
Expand All @@ -727,6 +737,7 @@ export const openCodexThread = (input: {
}): Effect.Effect<typeof CodexThreadResumeMetadata.Type, CodexErrors.CodexAppServerError> => {
const resumeThreadId = input.resumeThreadId;
const startParams = buildThreadStartParams({
configuredDeveloperInstructions: input.configuredDeveloperInstructions,
cwd: input.cwd,
runtimeMode: input.runtimeMode,
model: input.requestedModel,
Expand Down Expand Up @@ -2368,8 +2379,13 @@ export const makeCodexSessionRuntime = (
yield* client.notify("initialized", undefined);

const requestedModel = normalizeCodexModelSlug(options.model);
const { config } = yield* client.request("config/read", {
cwd: options.cwd,
includeLayers: false,
});

const opened = yield* openCodexThread({
configuredDeveloperInstructions: config.developer_instructions,
client,
threadId: options.threadId,
runtimeMode: options.runtimeMode,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/RuntimeInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.
</pull_request_linking>`;

const HTML_VISUALIZATION_INSTRUCTIONS = `<html_visualizations>
For a useful inline visualization, emit a complete top-level Markdown code fence with language t3-html and optional title="...". T3 Code web and desktop render these fences directly in the timeline after the assistant message finishes; mobile displays their source. Use self-contained HTML, inline CSS, and inline SVG. Native details/summary and CSS checkbox/radio toggles work. Scripts, event handlers, forms, navigation, external resources (including images and fonts), and access to the app are blocked. The visualization inherits the chat typography and live theme, and grows with its content. Use transparent backgrounds, currentColor, and the provided CSS variables: --foreground, --muted-foreground, --background, --card, --primary, --secondary, --accent, --border, --success, --warning, --destructive, --info, and --radius. Use these theme colors for graph strokes, fills, and labels instead of hardcoded palettes. Render the chart or diagram itself, without an outer card, page shell, duplicate title, or toolbar. Use responsive flow layout and width:100%; avoid fixed widths, viewport heights, nested scroll areas, and animations. Keep each visualization under 100,000 characters. Include a short plain-text explanation outside the fence so all clients can read the result. Ordinary html fences remain source code.
</html_visualizations>`;

/** Shared runtime context; omit model and effort when the harness manages them dynamically. */
export function buildRuntimeInstructions(runtime: {
readonly harness: string;
Expand All @@ -13,7 +17,7 @@ export function buildRuntimeInstructions(runtime: {
const effort = toSingleLine(runtime.reasoningEffort ?? "");
const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}\n\n${HTML_VISUALIZATION_INSTRUCTIONS}`;
}

function toSingleLine(value: string): string {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ rl.on("line", (line) => {
write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } });
return;
}
if (method === "config/read") {
write({ id, result: { config: {}, origins: {} } });
return;
}
if (method === "skills/list" || method === "model/list") {
write({ id, result: { data: [] } });
return;
Expand Down
5 changes: 4 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"build": "vp build",
"preview": "vp preview",
"typecheck": "tsc --noEmit",
"test": "vp test run --passWithNoTests --project unit"
"test": "vp test run --passWithNoTests --project unit",
"test:html-visualization": "playwright test"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
Expand All @@ -34,6 +35,7 @@
"@tanstack/react-router": "^1.160.2",
"class-variance-authority": "^0.7.1",
"culori": "^4.0.2",
"dompurify": "^3.4.15",
"effect": "catalog:",
"hast-util-to-html": "^9.0.5",
"hast-util-to-jsx-runtime": "^2.3.6",
Expand All @@ -56,6 +58,7 @@
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/vitest": "catalog:",
"@playwright/test": "^1.63.0",
"@rolldown/plugin-babel": "^0.2.0",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-plugin": "^1.161.0",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as NodeOS from "node:os";
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./tests",
testMatch: "html-visualization.spec.ts",
fullyParallel: false,
workers: 1,
outputDir: `${NodeOS.tmpdir()}/t3-html-visualization-${process.pid}`,
reporter: "list",
use: { browserName: "chromium", viewport: { width: 900, height: 700 } },
});
53 changes: 53 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,59 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps<typeof Button>;
}

describe("assistant HTML visualizations", () => {
it("keeps incomplete, streaming, ordinary, oversized, and non-assistant fences as source", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
const complete =
'```t3-html title="Example"\n<details><summary>More</summary>Detail</details>\n```';
try {
for (const props of [
{ text: complete },
{ text: complete, allowHtmlVisualizations: true, isStreaming: true },
{ text: complete.slice(0, -3), allowHtmlVisualizations: true },
{ text: complete.slice(0, -3) + " ```", allowHtmlVisualizations: true },
{ text: complete.slice(0, -3) + "~~~", allowHtmlVisualizations: true },
{ text: complete.replace("t3-html", "html"), allowHtmlVisualizations: true },
{ text: "```t3-html\n" + "x".repeat(100_001) + "\n```", allowHtmlVisualizations: true },
{
text: '<pre><code class="language-t3-html">hello</code></pre>',
allowHtmlVisualizations: true,
},
]) {
await act(async () => {
renderer?.unmount();
renderer = create(<ChatMarkdown cwd={undefined} {...props} />);
});
expect(renderer!.root.findAllByType("iframe")).toHaveLength(0);
expect(codeButton(renderer!, "Copy code").onClick).toBeTypeOf("function");
}
await act(async () => {
renderer!.update(<ChatMarkdown cwd={undefined} text={complete} allowHtmlVisualizations />);
});
expect(renderer!.root.findAllByType("iframe")).toHaveLength(1);
await act(async () => {
renderer!.root
.findAllByType(Button)
.find((button) => button.props.children === "Show source")!
.props.onClick();
});
expect(renderer!.root.findAllByType("iframe")).toHaveLength(0);
expect(codeButton(renderer!, "Copy code").onClick).toBeTypeOf("function");
await act(async () => {
renderer!.root
.findAllByType(Button)
.find((button) => button.props.children === "Show visualization")!
.props.onClick();
});
expect(renderer!.root.findAllByType("iframe")).toHaveLength(1);
} finally {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
}
});
});

describe("ChatMarkdown context references", () => {
it("renders text and image references through the chip renderer, with readable fallback", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
Expand Down
36 changes: 33 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ import {
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
import { HtmlVisualization } from "./chat/HtmlVisualization";
import { isHtmlVisualizationFence } from "../html-visualization";

interface ChatMarkdownProps {
text: string;
Expand All @@ -205,6 +207,8 @@ interface ChatMarkdownProps {
environmentId?: EnvironmentId | undefined;
onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
isStreaming?: boolean;
/** Opt in only for assistant timeline messages. */
allowHtmlVisualizations?: boolean;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
className?: string;
/** Treat single newlines as hard breaks — chat-style user input. */
Expand Down Expand Up @@ -2220,6 +2224,7 @@ function useChatMarkdownState({
environmentId: explicitEnvironmentId,
onTaskListChange,
isStreaming = false,
allowHtmlVisualizations = false,
skills = EMPTY_MARKDOWN_SKILLS,
onUseArtifactTemplate,
imageBaseDir,
Expand Down Expand Up @@ -2618,6 +2623,7 @@ function useChatMarkdownState({

const componentState = useMemo(
() => ({
allowHtmlVisualizations,
cwd,
diffThemeName,
environmentId,
Expand Down Expand Up @@ -2647,6 +2653,7 @@ function useChatMarkdownState({
updateThreadPullRequestLink,
}),
[
allowHtmlVisualizations,
cwd,
diffThemeName,
environmentId,
Expand Down Expand Up @@ -3177,15 +3184,17 @@ const CHAT_MARKDOWN_COMPONENTS = {
return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
},
pre: function MarkdownPre({ node, children, ...props }) {
const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
const { resolvedTheme, diffThemeName, isStreaming, allowHtmlVisualizations, text } = use(
ChatMarkdownRendererContext,
);
const codeBlock = extractCodeBlock(children);
if (!codeBlock) {
return <pre {...props}>{children}</pre>;
}

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
return (
const source = (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
Expand All @@ -3206,7 +3215,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
}
>
<SuspenseShikiCodeBlock
className={codeBlock.className}
className={language === "t3-html" ? "language-html" : codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
Expand All @@ -3215,6 +3224,27 @@ const CHAT_MARKDOWN_COMPONENTS = {
</RenderErrorBoundary>
</MarkdownCodeBlock>
);
const start = node?.position?.start.offset;
const end = node?.position?.end.offset;
if (
allowHtmlVisualizations &&
!isStreaming &&
language === "t3-html" &&
start !== undefined &&
end !== undefined &&
isHtmlVisualizationFence(text.slice(start, end), codeBlock.code)
) {
return (
<HtmlVisualization
html={codeBlock.code}
title={fenceTitle ?? "HTML visualization"}
dark={resolvedTheme === "dark"}
>
{source}
</HtmlVisualization>
);
}
return source;
},
} satisfies Components;

Expand Down
Loading
Loading