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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ Upstream: t3code 0.0.31
### Upstream t3code

- Undoing turns in Codex threads keeps working on Codex 0.156 (#13481 by Julius Marminge)
- Typing in the composer no longer lags on large sidebars (#13884 by Morgana Allen)
- Saving the thread list for offline use no longer freezes the app (#13767 by Theo Browne)
- Slow servers load the thread list once instead of twice (#13683 by Theo Browne)
- Terminals kept open in the background use less memory (#13686 by Theo Browne)
- The sidebar sorts projects and settled threads faster (#13759 by Theo Browne)
- File links in the terminal no longer include a trailing colon (#13408 by ValeraZSD)
- Copying nested task lists keeps each checkbox on the right item (#11477 by Dominic Roy)
- Selected text stays visible on a file line opened from a link (#13548 by Theo Browne)
- When browser preview tools fail, agents are told what to try instead (#13559 by Theo Browne)
- Browser preview snapshots stay small enough for agents to read in full (#13558 by Theo Browne)

## 0.0.31-avicode.9.16 (2026-09-24)

Expand Down
314 changes: 313 additions & 1 deletion apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { McpSchema, McpServer } from "effect/unstable/ai";
import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
Expand All @@ -16,6 +18,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test");
const threadId = ThreadId.make("thread-mcp-test");
const tabId = PreviewTabId.make("tab-mcp-test");
const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
const invocation = {
environmentId,
threadId,
Expand All @@ -38,6 +41,57 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
);

const snapshotResult = {
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
};

/** Answers every snapshot request on a fresh broker host with the given result. */
const serveSnapshots = (clientId: string, result: unknown) =>
Effect.gen(function* () {
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const connected = yield* Deferred.make<void>();
const inputs: Array<unknown> = [];
const events = yield* broker.connect({ clientId, environmentId });
yield* Stream.runForEach(events, (event) => {
if (event.type === "connected") return Deferred.succeed(connected, undefined);
inputs.push(event.request.input);
return broker.respond({
clientId,
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result,
});
}).pipe(Effect.forkScoped);
yield* Deferred.await(connected);
return inputs;
});

const callSnapshot = (args: Record<string, unknown>) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
return yield* server
.callTool({ name: "preview_snapshot", arguments: args })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);
});

it("normalizes empty successful notification responses to accepted", () => {
const notificationResponse = McpHttpServer.normalizeMcpHttpResponse(
HttpServerResponse.text("", { status: 200, contentType: "application/json" }),
Expand Down Expand Up @@ -83,19 +137,277 @@ it.effect("returns bounded structural preview snapshot failures", () =>
Effect.provideService(McpSchema.McpServerClient, client),
);

const message = "Preview automation snapshot failed on client mcp-failure-client.";
expect(snapshot.isError).toBe(true);
expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]);
expect(snapshot.content).toEqual([
{ type: "text", text: `Preview snapshot failed: ${message}` },
]);
expect(snapshot.structuredContent).toEqual({
error: {
_tag: "PreviewAutomationExecutionError",
operation: "snapshot",
failureCount: 1,
message,
},
});
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect.each([
{ args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." },
{
args: { tabId: alternateTabId },
advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`,
},
])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const connected = yield* Deferred.make<void>();
const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId });
yield* Stream.runForEach(events, (event) =>
event.type === "connected"
? Deferred.succeed(connected, undefined)
: broker.respond({
clientId: "mcp-no-tab-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: false,
error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" },
}),
).pipe(Effect.forkScoped);
yield* Deferred.await(connected);

const snapshot = yield* callSnapshot(args);

expect(snapshot.isError).toBe(true);
expect(snapshot.content).toEqual([
{ type: "text", text: `Preview snapshot failed: ${advice}` },
]);
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () =>
Effect.gen(function* () {
const snapshot = yield* callSnapshot({});

expect(snapshot.isError).toBe(true);
const [text] = snapshot.content;
expect(text?.type === "text" ? text.text : "").toContain(
"use a headless browser from the shell",
);
expect(snapshot.structuredContent).toMatchObject({
error: { _tag: "PreviewAutomationNoAvailableHostError" },
});
}).pipe(Effect.provide(TestLayer)),
);

it.effect("keeps the snapshot text under the agent's output ceiling", () =>
Effect.scoped(
Effect.gen(function* () {
// Mirrors the real failure: a [role] container whose innerText is the whole
// project list, repeated for several elements, plus a big AX tree.
const pageText = "/Users/theo/Code/project\nClaude, Codex · 79 threads\n".repeat(600);
const element = (name: string, index: number) => ({
tag: "div",
role: "presentation",
name,
selector: `div:nth-of-type(${index})`,
x: 0,
y: 0,
width: 10,
height: 10,
});
const oversized = {
...snapshotResult,
visibleText: pageText,
interactiveElements: [
element(pageText, 1),
element(pageText, 2),
element(pageText, 3),
element("Continue", 4),
],
accessibilityTree: { nodes: Array.from({ length: 2_000 }, (_, i) => ({ nodeId: `${i}` })) },
consoleEntries: Array.from({ length: 100 }, (_, i) => ({
level: "log",
text: `entry ${i}`,
timestamp: "t",
})),
};
yield* serveSnapshots("mcp-bounded-client", oversized);

const snapshot = yield* callSnapshot({});

expect(snapshot.isError).toBe(false);
const [text, notice] = snapshot.content;
expect(text?.type).toBe("text");
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
);
const parsed = decodeJsonText(body) as {
readonly accessibilityTree?: unknown;
readonly visibleText: string;
readonly interactiveElements: ReadonlyArray<{ readonly name: string }>;
readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
};
expect(parsed.accessibilityTree).toBeUndefined();
expect(parsed.visibleText.length).toBeLessThanOrEqual(8_001);
expect(parsed.interactiveElements).toHaveLength(4);
expect(parsed.interactiveElements[0]?.name.length).toBeLessThanOrEqual(201);
expect(parsed.interactiveElements[3]?.name).toBe("Continue");
expect(parsed.consoleEntries).toHaveLength(40);
expect(parsed.consoleEntries[0]?.text).toBe("entry 60");
expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree");
expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries");
// Claude Code shows the model structuredContent instead of the text, so it is bounded too.
expect(snapshot.structuredContent).toEqual({
...parsed,
omitted: expect.arrayContaining(["60 older console entries"]),
});
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("bounds the snapshot text even when nothing but logs and the title are large", () =>
Effect.scoped(
Effect.gen(function* () {
const oversized = {
...snapshotResult,
title: "t".repeat(70_000),
interactiveElements: [],
consoleEntries: [{ level: "log", text: "x".repeat(70_000), timestamp: "t" }],
};
yield* serveSnapshots("mcp-bounded-logs-client", oversized);

const snapshot = yield* callSnapshot({});

const [text] = snapshot.content;
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
);
const parsed = decodeJsonText(body) as {
readonly title: string;
readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
};
expect(parsed.title.length).toBe(2_049);
expect(parsed.consoleEntries[0]?.text.length).toBe(501);
const notice = snapshot.content[1];
const noticeText = notice?.type === "text" ? notice.text : "";
expect(noticeText).toContain("url or title after 2048 characters");
expect(noticeText).toContain("console entries text after 500 characters");
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("bounds page text made of wide characters before dropping locators", () =>
Effect.scoped(
Effect.gen(function* () {
// The character caps alone leave 8,000 three-byte characters, about 24 KB.
yield* serveSnapshots("mcp-wide-text-client", {
...snapshotResult,
visibleText: "界".repeat(9_000),
interactiveElements: Array.from({ length: 20 }, (_, i) => ({
tag: "button",
role: "button",
name: `Button ${i}`,
selector: `#button-${i}`,
x: 0,
y: 0,
width: 10,
height: 10,
})),
});

const snapshot = yield* callSnapshot({});

const [text, notice] = snapshot.content;
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
);
const parsed = decodeJsonText(body) as {
readonly visibleText: string;
readonly interactiveElements: ReadonlyArray<unknown>;
};
expect(parsed.visibleText).toMatch(/^界+…$/);
expect(parsed.interactiveElements).toHaveLength(20);
expect(notice?.type === "text" ? notice.text : "").toContain(
"visibleText after 4000 characters",
);
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("sheds log entries before locators when every list is full", () =>
Effect.scoped(
Effect.gen(function* () {
const long = "x".repeat(2_000);
const oversized = {
...snapshotResult,
interactiveElements: Array.from({ length: 20 }, (_, i) => ({
tag: "button",
role: "button",
name: `Button ${i}`,
selector: `#button-${i}`,
x: 0,
y: 0,
width: 10,
height: 10,
})),
consoleEntries: Array.from({ length: 200 }, () => ({
level: long,
text: long,
timestamp: long,
source: long,
})),
networkEntries: Array.from({ length: 200 }, () => ({
url: long,
method: long,
status: 200,
failed: false,
errorText: long,
timestamp: long,
})),
actionTimeline: Array.from({ length: 200 }, () => ({
id: long,
action: long,
status: "succeeded",
startedAt: long,
completedAt: long,
error: long,
})),
};
yield* serveSnapshots("mcp-full-logs-client", oversized);

const snapshot = yield* callSnapshot({});

const [text, notice] = snapshot.content;
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
);
const parsed = decodeJsonText(body) as {
readonly interactiveElements: ReadonlyArray<unknown>;
readonly consoleEntries: ReadonlyArray<unknown>;
readonly networkEntries: ReadonlyArray<unknown>;
readonly actionTimeline: ReadonlyArray<unknown>;
};
// Locators survive; the log lists take the cut.
expect(parsed.interactiveElements).toHaveLength(20);
expect(
parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length,
).toBeLessThan(120);
const noticeText = notice?.type === "text" ? notice.text : "";
expect(noticeText).toContain("40 of 40 actionTimeline");
expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/);
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("terminates HTTP MCP sessions with DELETE", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading