Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd394ac
fix(acp): Antigravity html_render pages show in the thread
neo-clon Oct 15, 2026
021c734
fix(acp): a repeated html_render completion keeps its page
neo-clon Oct 15, 2026
d55f3e5
fix(acp): an html_render that returned an error keeps the error
neo-clon Oct 15, 2026
d935233
fix(acp): an html_render that reports its page takes the kept page
neo-clon Oct 15, 2026
59e737b
fix(acp): an html_render call takes the page published with its html
neo-clon Oct 15, 2026
465c13b
fix(acp): each thread keeps its own most recent html_render pages
neo-clon Oct 15, 2026
34b2c12
Merge remote-tracking branch 'origin/main' into fix/antigravity-html-…
neo-clon Oct 16, 2026
2329bc5
fix(server): interrupted html_render publish rolls back file and sess…
neo-clon Oct 16, 2026
db808e1
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 16, 2026
21e9dc6
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 16, 2026
4cc2dfd
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 16, 2026
f27e747
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 16, 2026
fbba118
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 17, 2026
bf0bf31
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 17, 2026
b6ea4ff
Merge branch 'main' into fix/antigravity-html-render
neo-clon Oct 17, 2026
1db4811
Merge origin/main into fix/antigravity-html-render
juliusmarminge Oct 19, 2026
96ee923
fix(acp): retain Antigravity published pages with focused coverage
juliusmarminge Oct 19, 2026
9915442
Merge origin/main into fix/antigravity-html-render
juliusmarminge Oct 19, 2026
a89315c
Merge remote-tracking branch 'origin/main' into fix/antigravity-html-…
juliusmarminge Oct 19, 2026
5e1d626
Merge remote-tracking branch 'origin/main' into L71-19796
juliusmarminge Oct 20, 2026
3921473
refactor(provider-core): use Effect.fn for McpProviderSessions.set
juliusmarminge Oct 20, 2026
41e1faf
Merge remote-tracking branch 'origin/main' into L71-19796
juliusmarminge Oct 20, 2026
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
22 changes: 21 additions & 1 deletion apps/server/src/htmlRender/HtmlRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import { ThreadId } from "@t3tools/contracts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as HostProcess from "@t3tools/shared/HostProcess";
import {
HTML_RENDER_MEASURE_FONTS,
Expand Down Expand Up @@ -49,6 +50,7 @@ const layerHtmlRender = (
}),
),
),
Layer.provideMerge(McpProviderSessions.layer),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-html-render-" })),
Layer.provideMerge(NodeServices.layer),
);
Expand Down Expand Up @@ -175,9 +177,11 @@ describe("HtmlRender", () => {
const fileSystem = yield* FileSystem.FileSystem;
const config = yield* ServerConfig.ServerConfig;
const htmlRender = yield* HtmlRender.HtmlRender;
const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
const threadId = ThreadId.make("thread-html-render");

const reference = yield* htmlRender.publish({
threadId: ThreadId.make("thread-html-render"),
threadId,
html: "<p>Quarterly revenue</p>",
title: " Revenue ",
height: 9_000,
Expand All @@ -197,6 +201,15 @@ describe("HtmlRender", () => {
const html = yield* fileSystem.readFileString(stored ?? "");
expect(html).toContain('<style id="t3-theme">');
expect(html).toContain("<p>Quarterly revenue</p>");
// The thread's provider session can attach the page to a call reported
// without its result, matched by the call's own arguments, once.
const page = { title: " Revenue ", height: 9_000 };
expect(yield* mcpSessions.takeHtmlRender(ThreadId.make("thread-other"), page)).toBe(
undefined,
);
expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, height: 400 })).toBe(undefined);
expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(reference);
expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
}).pipe(Effect.provide(layerTest)),
);

Expand All @@ -223,6 +236,13 @@ describe("HtmlRender", () => {
expect(yield* storedPages).toHaveLength(1);
yield* Fiber.interrupt(publishing);
expect(yield* storedPages).toEqual([]);
const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
expect(
yield* mcpSessions.takeHtmlRender(ThreadId.make("thread-html-cancel"), {
title: "X",
height: 200,
}),
).toBe(undefined);
}).pipe(
Effect.provide(
layerHtmlRender(
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/htmlRender/HtmlRender.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ThreadId } from "@t3tools/contracts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as HostProcess from "@t3tools/shared/HostProcess";
import {
clampHtmlRenderHeight,
Expand Down Expand Up @@ -101,7 +102,8 @@ export class HtmlRender extends Context.Service<
/**
* Stores a prepared page as a thread attachment for clients to show inline,
* measuring its height at each client width when the preview browser is
* already installed.
* already installed. The thread's provider session keeps the reference for
* an agent that reports the call without its result.
*/
readonly publish: (input: {
readonly threadId: ThreadId;
Expand Down Expand Up @@ -335,6 +337,7 @@ const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const config = yield* ServerConfig.ServerConfig;
const previewBrowser = yield* PreviewBrowser.PreviewBrowser;
const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
const services = yield* Effect.context<
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
>();
Expand Down Expand Up @@ -439,12 +442,14 @@ const make = Effect.gen(function* () {
// publish that fails or is interrupted before returning removes it.
Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)),
);
return {
const reference: HtmlRenderReference = {
attachmentId,
title: input.title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML",
height: clampHtmlRenderHeight(input.height),
...(heights === undefined ? {} : { heights }),
} satisfies HtmlRenderReference;
};
yield* mcpSessions.recordHtmlRender(input.threadId, input, reference);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return reference;
});

const preview = Effect.fn("HtmlRender.preview")(function* (input: {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as Effect from "effect/Effect";
import * as TestClock from "effect/testing/TestClock";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -293,6 +294,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both
Effect.provide(
McpHttpServer.layerHtmlToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(McpProviderSessions.layer),
Layer.provide(PreviewBrowser.layer),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })),
Layer.provide(NodeServices.layer),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -57,6 +58,7 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}),
Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
Layer.mock(ThreadSearch.ThreadSearch)({}),
McpProviderSessions.layer,
);

const ToolsListPayload = Schema.fromJsonString(
Expand Down
200 changes: 200 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -518,3 +519,202 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", (
}).pipe(Effect.provide(layerSession), Effect.scoped),
);
});

describe("AntigravityAdapterV2 html_render pages", () => {
it.effect("shows the page html_render published when the agent reports only a summary", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
let sessionUpdate: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
const instanceId = ProviderInstanceId.make("antigravity-html-render-test");
const threadId = ThreadId.make("thread-antigravity-html-render");
const page = (name: string) => ({
attachmentId: `${threadId}-${name}-html`,
title: "Revenue",
height: 240,
});
const revenue = { html: "<p>Revenue</p>", title: "Revenue", height: 240 };
const adapter = yield* makeAntigravityAdapterV2({
instanceId,
selfInvocation: yield* resolveSelfInvocation(),
makeRuntime: (input) =>
makeAntigravityAcpRuntime({
...input,
childProcessSpawner,
spawn: {
command: process.execPath,
args: [mockAgentPath],
cwd: input.cwd,
env: { T3_ACP_ANTIGRAVITY: "1" },
},
}).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((runtime): RuntimeService => ({
...runtime,
handleSessionUpdate: (handler) =>
Effect.sync(() => {
sessionUpdate = handler;
}).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
prompt: () =>
Effect.gen(function* () {
const send = (update: EffectAcpSchema.SessionUpdate) =>
sessionUpdate === undefined
? Effect.die("the adapter must handle session updates")
: sessionUpdate({ sessionId: "mock-session-1", update });
// agy_acp_server 1.3.0 and 1.4.0 without a permission prompt:
// the title is "Running <tool>", rawInput holds only the
// tool's arguments, and the completion reports the step's
// own summary as rawOutput instead of the MCP result.
const start = (toolCallId: string, rawInput: typeof revenue) =>
send({
sessionUpdate: "tool_call",
toolCallId,
title: "Running html_render",
kind: "other",
status: "in_progress",
rawInput,
});
yield* start("trajectory:1", revenue);
yield* start("trajectory:2", { ...revenue, title: "Costs" });
yield* start("trajectory:3", revenue);
// T3's MCP server publishes while each call runs.
yield* mcpSessions.recordHtmlRender(threadId, revenue, page("first"));
yield* mcpSessions.recordHtmlRender(threadId, revenue, page("second"));
yield* send({
sessionUpdate: "tool_call_update",
toolCallId: "trajectory:1",
status: "completed",
rawOutput: "Render revenue chart",
});
// A call with no page published for its arguments stays as reported.
yield* send({
sessionUpdate: "tool_call_update",
toolCallId: "trajectory:2",
status: "completed",
rawOutput: "Render costs chart",
});
// A failed call does not claim a page.
yield* send({
sessionUpdate: "tool_call_update",
toolCallId: "trajectory:3",
status: "failed",
rawOutput: "MCP call timed out",
});
return { stopReason: "end_turn" as const };
}),
})),
),
withProcess: (_stop, task) => task,
defaultModel: Effect.succeed(undefined),
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "gemini-test-low" } as const;
const session = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-antigravity-html-render"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* session.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
const now = yield* DateTime.now;
yield* session.startTurn({
appThread: {
createdBy: "user",
creationSource: "web",
id: threadId,
projectId: ProjectId.make("project-antigravity-html-render"),
title: "Antigravity html_render",
providerInstanceId: instanceId,
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: process.cwd(),
activeProviderThreadId: providerThread.id,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
},
threadId,
runId: RunId.make("run-antigravity-html-render"),
runOrdinal: 1,
providerTurnOrdinal: 1,
attemptId: RunAttemptId.make("attempt-antigravity-html-render"),
rootNodeId: NodeId.make("node-antigravity-html-render"),
providerThread,
message: {
createdBy: "user",
creationSource: "web",
messageId: MessageId.make("message-antigravity-html-render"),
text: "chart the revenue",
attachments: [],
},
modelSelection,
runtimePolicy,
});
const events = yield* session.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
);
const finalItem = (nativeId: string) =>
Array.from(events).findLast(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.nativeItemRef?.nativeId === nativeId,
);
const tool = (nativeId: string) => {
const event = finalItem(nativeId);
const item = event?.type === "turn_item.updated" ? event.turnItem : undefined;
return item?.type === "dynamic_tool"
? {
status: item.status,
toolName: item.toolName,
page: htmlRenderFromToolItem(item),
output: item.output,
}
: undefined;
};

assert.deepEqual(tool("trajectory:1"), {
status: "completed",
toolName: "t3-code.html_render",
page: page("first"),
output: { htmlRender: page("first") },
});
assert.deepEqual(tool("trajectory:2"), {
status: "completed",
toolName: "t3-code.html_render",
page: undefined,
output: "Render costs chart",
});
assert.deepEqual(tool("trajectory:3"), {
status: "failed",
toolName: "t3-code.html_render",
page: undefined,
output: "MCP call timed out",
});
// Each page goes to one call; the unclaimed one stays for a later call.
assert.deepEqual(yield* mcpSessions.takeHtmlRender(threadId, revenue), page("second"));
}).pipe(Effect.provide(layerSession), Effect.scoped),
);
});
Loading
Loading