Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6db95d2
fix(shared): show html_render pages published through OpenCode Code M…
ehfreema Oct 8, 2026
b1656f4
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 8, 2026
f522d38
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 8, 2026
cb0e5e8
fix(shared,acp): recover html_render pages from wrapped provider enve…
ehfreema Oct 11, 2026
397c1c4
Merge remote-tracking branch 'origin/main' into fix/execute-html-rend…
ehfreema Oct 11, 2026
292d345
fix(shared): isolate bare-text parse budget in tool output reader
ehfreema Oct 11, 2026
c0222ea
fix(shared): restrict bare-text fallback to typed text blocks
ehfreema Oct 11, 2026
dcebc1c
fix(shared): keep outer metadata when text block parses empty
ehfreema Oct 11, 2026
efb8604
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 11, 2026
a6615b3
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 11, 2026
8de156d
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 11, 2026
a010357
Merge branch 'main' into fix/execute-html-render-inline
ehfreema Oct 13, 2026
bc084fe
fix(shared): read a bare html_render reference an execute script retu…
ehfreema Oct 13, 2026
c049ef3
fix(server): OpenCode 2 keeps html_render out of Code Mode so every p…
ehfreema Oct 13, 2026
ae99a51
Revert "fix(server): OpenCode 2 keeps html_render out of Code Mode so…
ehfreema Oct 14, 2026
dec3615
Merge remote-tracking branch 'origin/main' into fix/execute-html-rend…
ehfreema Oct 16, 2026
0e2e4e6
Merge remote-tracking branch 'origin/main' into fix/execute-html-rend…
ehfreema Oct 22, 2026
79d5986
fix(shared): only show an execute script's page when it belongs to th…
ehfreema Oct 23, 2026
ed7d8da
Merge remote-tracking branch 'origin/main' into fix/execute-html-rend…
ehfreema Oct 24, 2026
f4c02b8
fix: add takeover regression tests for wrapped inline pages
juliusmarminge Oct 26, 2026
6374f21
fix: show pages that OpenCode execute scripts and ACP agents return i…
juliusmarminge Oct 26, 2026
ee7deb1
Merge remote-tracking branch 'origin/main' into HEAD
juliusmarminge Oct 26, 2026
34fcb08
fix(acp): preserve tool identity and unwrap child MCP output
juliusmarminge Oct 27, 2026
b45a89b
Merge commit '977a18823cf8e067476193e29947e2d3950a25f2' into HEAD
juliusmarminge Oct 27, 2026
cc71861
fix: integrate inline-page takeover while preserving contributor history
juliusmarminge Oct 27, 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
27 changes: 27 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ it("keeps historical plan detail accessible from its paged turn item", () => {
expect(presented[2]).toMatchObject({ activities: [{ projectedItem: { item } }] });
});

it("shows a page an execute script returned only when it was published for the row's thread", () => {
const executeCall = (id: string, thread: string) =>
({
...base(id, "2026-06-20T00:00:02.000Z", 1),
type: "dynamic_tool",
toolName: "execute",
input: { code: "return await tools.render()" },
output: JSON.stringify({
htmlRender: {
attachmentId: `${thread}-00000000-0000-4000-8000-000000000001-html`,
title: "Chart",
height: 420,
},
}),
}) as OrchestrationV2TurnItem;
const pages = (row: OrchestrationV2ProjectedTurnItem) =>
buildThreadFeed([row]).filter((entry) => entry.type === "html-render");

expect(pages(projected(executeCall("own", threadId), 0))).toHaveLength(1);
expect(pages(projected(executeCall("foreign", "thread-2"), 0))).toEqual([]);
// An inherited row is checked against the thread it came from, not the one showing it.
expect(pages(projected(executeCall("inherited", sourceThreadId), 0, "inherited"))).toHaveLength(
1,
);
expect(pages(projected(executeCall("inherited-local", threadId), 0, "inherited"))).toEqual([]);
});

it("shows a compact command as one mobile divider before, during, and after compaction", () => {
const startedAt = "2026-06-20T00:00:01.000Z";
const prompt = projected({ ...userMessage(), text: "/compact", idleCompaction: "onReturn" }, 0);
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2189,7 +2189,7 @@ export function buildThreadFeed(
const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
const render =
item.type === "dynamic_tool" && item.status === "completed"
? htmlRenderFromToolItem(item)
? htmlRenderFromToolItem(item, row.sourceThreadId)
: undefined;
if (render) {
const entry: RawThreadFeedEntry = {
Expand Down
26 changes: 8 additions & 18 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import type { ChatAttachment } from "@t3tools/contracts";
import {
PENDING_ATTACHMENT_THREAD_SEGMENT,
toSafeThreadAttachmentSegment,
} from "@t3tools/shared/attachmentIds";
import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";

import {
Expand All @@ -13,7 +17,6 @@ import {
import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";

const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";
Expand All @@ -22,25 +25,11 @@ const ATTACHMENT_ID_PATTERN = new RegExp(
"i",
);

export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending";
export { PENDING_ATTACHMENT_THREAD_SEGMENT, toSafeThreadAttachmentSegment };

const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000;

export function toSafeThreadAttachmentSegment(threadId: string): string | null {
const segment = threadId
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS)
.replace(/[-_]+$/g, "");
if (segment.length === 0) {
return null;
}
return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
}

export function attachmentFileExtension(fileName: string): string {
const extension = NodePath.extname(fileName).toLowerCase();
// .part is reserved for pending uploads; the stale-upload sweep would delete stored files.
Expand Down Expand Up @@ -133,7 +122,8 @@ export function threadHtmlRenderAttachmentIds(
if (segment === null) return [];
return Array.from(items).flatMap((item) => {
const attachmentId =
htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId;
htmlRenderFromToolItem(item, threadId)?.attachmentId ??
mcpAppFromToolItem(item)?.attachmentId;
return attachmentId !== undefined &&
parseThreadSegmentFromAttachmentId(attachmentId) === segment
? [attachmentId]
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/guest/GuestShareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ const make = Effect.gen(function* () {
const render = Effect.fn("GuestShareService.render")(function* (session: string, itemId: string) {
const { thread, item } = yield* threadItem(session, itemId);
if (item.type !== "dynamic_tool") return yield* unavailable;
const reference = htmlRenderFromToolItem(item);
const reference = htmlRenderFromToolItem(item, thread.id);
const [attachmentId] = threadHtmlRenderAttachmentIds(thread.id, [item]);
const path =
attachmentId === undefined
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { GrokSettings } from "@t3tools/provider-grok/settings";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { deriveBlockedOnThreadWait } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import type * as Duration from "effect/Duration";
Expand Down Expand Up @@ -79,6 +80,7 @@ import {
acpScopedNativeId,
acpPostSettleContinuationOfferEvidence,
acpPostSettleWakeEvidence,
acpMcpToolCallOutput,
acpToolCallDiffPatch,
makeAcpAdapterV2,
type AcpAdapterV2ExtensionContext,
Expand Down Expand Up @@ -110,6 +112,22 @@ const layerTest = Layer.mergeAll(
const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));

describe("acpMcpToolCallOutput", () => {
it("unwraps Grok's OkayOutput result string", () => {
const inner = JSON.stringify({ htmlRender: { attachmentId: "t-html", title: "T", height: 1 } });
assert.strictEqual(
acpMcpToolCallOutput({
type: "MCP",
tool_name: "html_render",
output: { OkayOutput: inner },
}),
inner,
);
const nested = { type: "MCP", output: { OkayOutput: { nested: true } } };
assert.strictEqual(acpMcpToolCallOutput(nested), nested);
});
});

describe("acpToolCallDiffPatch", () => {
it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => {
assert.equal(
Expand Down Expand Up @@ -1538,6 +1556,7 @@ describe("AcpAdapterV2", () => {

it.effect("keeps Devin parent paragraphs intact while projecting native child work", () =>
Effect.gen(function* () {
const childPage = { attachmentId: "child-html", title: "Child page", height: 120 };
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const mockAgentPath = yield* path.fromFileUrl(
Expand Down Expand Up @@ -1649,6 +1668,21 @@ describe("AcpAdapterV2", () => {
"cognition.ai/subagent_context": { parentAgentId: "child-a" },
},
},
{
sessionUpdate: "tool_call",
toolCallId: "child-html",
title: "Render page",
status: "completed",
rawInput: { server: "t3-code", tool: "html_render", arguments: {} },
rawOutput: {
type: "MCP",
output: { OkayOutput: JSON.stringify({ htmlRender: childPage }) },
},
_meta: {
is_mcp_tool_call: true,
"cognition.ai/subagent_context": { parentAgentId: "child-a" },
},
},
{
sessionUpdate: "tool_call",
toolCallId: "child-mcp-fallback",
Expand Down Expand Up @@ -1821,6 +1855,16 @@ describe("AcpAdapterV2", () => {
item.type === "dynamic_tool" &&
item.toolName === "weather.get_weather",
);
const childHtml = items.findLast(
(item) =>
item.threadId === task?.childThreadId &&
item.type === "dynamic_tool" &&
item.toolName === "t3-code.html_render",
);
assert.deepEqual(
childHtml?.type === "dynamic_tool" ? htmlRenderFromToolItem(childHtml) : undefined,
childPage,
);
for (const item of [parentMcp, childMcp]) {
assert.equal(item?.title, "get weather");
assert.deepEqual(item?.toolSource, {
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5323,7 +5323,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
FROM orchestration_v2_projection_turn_items
WHERE thread_id = ${threadId}
AND type = 'dynamic_tool'
AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`})
AND (
payload_json LIKE '%htmlRender%'
-- An execute script can return a page's reference without the htmlRender key.
OR payload_json LIKE '%-html%'
OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}
)
`,
// A guest's waiting files are pending uploads that no message row holds yet.
sql<{ id: string }>`
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,41 @@ describe("HTML renders in the timeline", () => {
expect(rowsFor(entries, true)).toEqual(["message", "turn-fold", "attempt-fold", "html-render"]);
});

it("shows a page an execute script returned only when it was published for the row's thread", () => {
const page = (thread: string) => ({
attachmentId: `${thread}-00000000-0000-4000-8000-000000000001-html`,
title: "Chart",
height: 420,
});
const executeCall = (render: ReturnType<typeof page>): OrchestrationV2TurnItem => ({
...base("render", 2),
type: "dynamic_tool",
toolName: "execute",
input: { code: "return await tools.render()" },
output: JSON.stringify({ htmlRender: render }),
});
expect(
turn(executeCall(page("render-thread"))).find((e) => e.kind === "html-render"),
).toMatchObject({ htmlRender: page("render-thread") });
const foreign = turn(executeCall(page("other-thread")));
expect(foreign.some((entry) => entry.kind === "html-render")).toBe(false);
// A delegated child's row is checked against the child, not the thread showing it.
const child = ThreadId.make("child-thread");
const merged = deriveTimelineEntriesFromVisibleTurnItems({
visibleTurnItems: [
{ ...visible(executeCall(page("child-thread"))), sourceThreadId: child },
{
...visible({ ...executeCall(page("render-thread")), id: TurnItemId.make("render-2") }),
sourceThreadId: child,
},
],
optimisticMessages: [],
});
expect(merged.filter((entry) => entry.kind === "html-render").map((entry) => entry.id)).toEqual(
["render"],
);
});

it.each([
["running", "running", undefined],
["failed", "failed", { content: [{ type: "text", text: "Invalid HTML" }], isError: true }],
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems(

const htmlRender =
item.type === "dynamic_tool" && item.status === "completed"
? htmlRenderFromToolItem(item)
? htmlRenderFromToolItem(item, row.sourceThreadId)
: undefined;
if (htmlRender !== undefined) {
entries.push({
Expand Down
15 changes: 12 additions & 3 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,10 +700,14 @@ function makeProviderThread(input: {
};
}

function acpMcpToolCallOutput(rawOutput: unknown): unknown {
export function acpMcpToolCallOutput(rawOutput: unknown): unknown {
const record = unknownRecord(rawOutput);
if (record === undefined) return rawOutput;
if (!("result" in record) && !("error" in record)) return rawOutput;
if (!("result" in record) && !("error" in record)) {
// Grok serializes Rust's Result: `{ type: "MCP", output: { OkayOutput: "<json>" } }`.
const okay = unknownRecord(record.output)?.OkayOutput;
return typeof okay === "string" ? okay : rawOutput;
}
const result = unknownRecord(record.result);
const resultOutput =
result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined);
Expand Down Expand Up @@ -2668,7 +2672,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
? (merged.title ?? merged.kind ?? "Tool")
: `${mcpIdentity.server}.${mcpIdentity.tool}`,
input: merged.data.rawInput ?? null,
output: merged.data.rawOutput ?? merged.data.content ?? null,
output:
(mcpIdentity === undefined
? merged.data.rawOutput
: acpMcpToolCallOutput(merged.data.rawOutput)) ??
merged.data.content ??
null,
},
});
});
Expand Down
74 changes: 74 additions & 0 deletions packages/provider-acp/src/server/runtimeModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,80 @@ describe("extractMcpToolCallIdentity", () => {
});
});

it("recovers T3 identity from CodeBuddy's qualified rawInput.toolName", () => {
const toolCall = toolCallFromUpdate({
sessionUpdate: "tool_call",
toolCallId: "tool-1",
kind: "Tool",
title: "Tool",
status: "in_progress",
rawInput: { toolName: "mcp__t3-code__html_render", arguments: {} },
});

expect(extractMcpToolCallIdentity(toolCall)).toEqual({
server: "t3-code",
tool: "html_render",
});
});

it("ignores an unqualified rawInput.toolName such as a ToolSearch query", () => {
const toolCall = toolCallFromUpdate({
sessionUpdate: "tool_call",
toolCallId: "tool-1",
kind: "search",
title: "ToolSearch",
status: "in_progress",
rawInput: { toolName: "html_render", query: "render html" },
});

expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined();
});

it("recovers T3 identity from Devin's namespaced inference tool name", () => {
const toolCall = toolCallFromUpdate({
sessionUpdate: "tool_call",
toolCallId: "tool-2",
kind: "DeferExecuteTool",
title: "Deferred tool call",
status: "in_progress",
rawInput: { server_name: "other", tool_name: "html_render" },
_meta: { "cognition.ai/inferenceToolName": "mcp__t3-code__html_render" },
});

expect(extractMcpToolCallIdentity(toolCall)).toEqual({
server: "t3-code",
tool: "html_render",
});
});

it("recovers T3 identity from Grok's snake_case rawInput and annotated titles", () => {
const snakeCase = toolCallFromUpdate({
sessionUpdate: "tool_call",
toolCallId: "tool-3",
kind: "UseTool",
title: "UseTool",
status: "in_progress",
rawInput: { server_name: "t3-code", tool_name: "html_render" },
_meta: { is_mcp_tool_call: true },
});
const filePassed = toolCallFromUpdate({
sessionUpdate: "tool_call",
toolCallId: "tool-4",
kind: "UseTool",
title: "t3-code__html_render — source file: /tmp/mcp-render.json",
status: "in_progress",
rawInput: { tool_name: "html_render", tool_input: { height: 200 }, variant: "UseTool" },
_meta: { is_mcp_tool_call: true },
});

for (const toolCall of [snakeCase, filePassed]) {
expect(extractMcpToolCallIdentity(toolCall)).toEqual({
server: "t3-code",
tool: "html_render",
});
}
});

it("recovers T3 identity from acp-mcp-call fallback commands", () => {
const toolCall = toolCallFromUpdate({
sessionUpdate: "tool_call",
Expand Down
Loading
Loading