From 719703e5639695acd61155b30a926f5b62675736 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 19:58:20 -0400 Subject: [PATCH 1/3] fix(server): t3_thread_read no longer splits an emoji across text pages When an item's text was longer than maxCharsPerItem, the page ended at a UTF-16 index. An emoji or other astral character on the boundary was cut in half: the page ended with a lone high surrogate and the next page, read from nextTextOffset, started with the lone low one. End the page before the pair instead, or take the whole pair when the page would otherwise be empty, so paging still makes progress. Co-Authored-By: Claude Opus 5.5 --- .../OrchestratorMcpService.activity.test.ts | 91 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 11 ++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 45561e7670411..20fab1f8f00dd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, NodeId, + type OrchestratorMcpThreadReadResult, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, ProjectId, @@ -9,6 +10,7 @@ import { RunId, ScheduledTaskId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -183,6 +185,95 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a }).pipe(Effect.provide(layer), Effect.runPromise); }); +it("readThread pages cut-off text without splitting an emoji across pages", async () => { + const text = "a😀bc"; + const itemId = TurnItemId.make("turn-item-mcp-reasoning"); + const projection = { + thread: baseThread({ + threadId: parentThreadId, + title: "Parent", + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })], + visibleTurnItems: [], + runtimeRequests: [], + messages: [], + contextTransfers: [], + subagents: [], + updatedAt: now, + } as unknown as OrchestrationV2ThreadProjection; + const row = { + position: 0, + visibility: "visible", + sourceThreadId: parentThreadId, + sourceItemId: itemId, + item: { + type: "reasoning", + runId: activeRunId, + status: "completed", + title: null, + text, + updatedAt: now, + }, + }; + + const layer = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getTimelinePage: () => + Effect.succeed({ + items: [row] as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"], + totalItems: 1, + hasMore: false, + }), + getThreadRecords: () => Effect.succeed(projection), + getProjectThreadRecords: () => Effect.succeed(projection), + getThreadShell: () => + Effect.succeed(projection.thread as unknown as OrchestrationV2ThreadShell), + } satisfies Partial), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [] }), + } satisfies Partial), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + } satisfies Partial), + NodeCrypto.layer, + ), + ), + ); + + await Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const pages = Effect.fn(function* (maxCharsPerItem: number) { + const read: Array = []; + let textOffset: number | null = 0; + while (textOffset !== null) { + const result: OrchestratorMcpThreadReadResult = yield* service.readThread(makeScope(), { + threadId: parentThreadId, + view: "activity", + itemId, + textOffset, + maxCharsPerItem, + }); + read.push(result.items[0]!.text); + textOffset = result.items[0]!.nextTextOffset ?? null; + } + return read; + }); + expect(yield* pages(2)).toEqual(["a", "😀", "bc"]); + expect(yield* pages(1)).toEqual(["a", "😀", "b", "c"]); + }).pipe(Effect.provide(layer), Effect.runPromise); +}); + it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => { const parentProjection = { thread: baseThread({ diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d8dacb35a0617..0d63868a43838 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -919,6 +919,15 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { } } +/** A cut inside a surrogate pair would leave half an emoji on each page. */ +function textPageEnd(text: string | null, offset: number, maxChars: number): number { + const end = offset + maxChars; + if (text === null || end >= text.length) return end; + const last = text.charCodeAt(end - 1); + if (last < 0xd800 || last > 0xdbff) return end; + return end - 1 > offset ? end - 1 : end + 1; +} + function timelineItem(input: { readonly row: OrchestrationV2ThreadProjection["visibleTurnItems"][number]; readonly maxChars: number; @@ -927,7 +936,7 @@ function timelineItem(input: { }): OrchestratorMcpThreadTimelineItem { const text = turnItemText(input.row.item); const offset = input.textOffset ?? 0; - const end = offset + input.maxChars; + const end = textPageEnd(text, offset, input.maxChars); const textTruncated = text !== null && text.length > end; const messageId = input.row.item.type === "user_message" || input.row.item.type === "assistant_message" From 4146161005775523353c366aed29a09f9c6b064a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 23:08:34 +0000 Subject: [PATCH 2/3] test(server): bound thread text pagination assertions --- .../OrchestratorMcpService.activity.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 64c2245dd6f53..f260093f3f161 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -1,7 +1,6 @@ import { EnvironmentId, NodeId, - type OrchestratorMcpThreadReadResult, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, ProjectId, @@ -275,24 +274,27 @@ it("readThread pages cut-off text without splitting an emoji across pages", asyn await Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const pages = Effect.fn(function* (maxCharsPerItem: number) { - const read: Array = []; - let textOffset: number | null = 0; - while (textOffset !== null) { - const result: OrchestratorMcpThreadReadResult = yield* service.readThread(makeScope(), { + for (const [maxCharsPerItem, expected] of [ + [2, ["a", "😀", "bc"]], + [1, ["a", "😀", "b", "c"]], + ] as const) { + let textOffset = 0; + for (const [index, text] of expected.entries()) { + const result = yield* service.readThread(makeScope(), { threadId: parentThreadId, view: "activity", itemId, textOffset, maxCharsPerItem, }); - read.push(result.items[0]!.text); - textOffset = result.items[0]!.nextTextOffset ?? null; + textOffset += text.length; + expect(result.items[0]).toMatchObject({ + text, + textTruncated: index < expected.length - 1, + nextTextOffset: index < expected.length - 1 ? textOffset : null, + }); } - return read; - }); - expect(yield* pages(2)).toEqual(["a", "😀", "bc"]); - expect(yield* pages(1)).toEqual(["a", "😀", "b", "c"]); + } }).pipe(Effect.provide(layer), Effect.runPromise); }); From be465c1512ced1dca3c5ea13b959ea0e13193cad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 23:11:14 +0000 Subject: [PATCH 3/3] fix(server): acknowledge complete emoji result pages --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../src/mcp/OrchestratorMcpToolkit.integration.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 4cc741e7b9cde..638541d6e4ca4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -627,7 +627,7 @@ function pageIncludesTerminalTaskResult(input: { if (!matchesResult) return false; const text = turnItemText(row.item); - return text !== null && text.length <= input.maxChars; + return text !== null && text.length <= textPageEnd(text, 0, input.maxChars); }); } diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e53a4d85583e4..73c1eef7e991c 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -195,6 +195,7 @@ describe("orchestrator MCP toolkit", () => { it.live.each([ { label: "distinct", result: queuedFollowupResult, latestSummary: queuedFollowupResult }, { label: "identical", result: delegatedResult, latestSummary: null }, + { label: "emoji", result: "😀", latestSummary: "😀" }, ])("reports $label child results", ({ result, latestSummary }) => Effect.scoped( Effect.gen(function* () { @@ -616,7 +617,11 @@ describe("orchestrator MCP toolkit", () => { }); const parentlessRead = yield* invoke( "t3_thread_read", - { threadId: delegated.childThreadId, limit: 100 }, + { + threadId: delegated.childThreadId, + limit: 100, + maxCharsPerItem: result === "😀" ? 1 : 20_000, + }, { ...invocation, thread: undefined }, ).pipe(Effect.withTracer(tracer)); expect(parentlessRead.isError).not.toBe(true); @@ -630,6 +635,7 @@ describe("orchestrator MCP toolkit", () => { const read = yield* invoke("t3_thread_read", { threadId: delegated.childThreadId, limit: 100, + maxCharsPerItem: result === "😀" ? 1 : 20_000, }).pipe(Effect.withTracer(tracer)); expect(read.isError).not.toBe(true); expect(transferReads.length).toBeGreaterThan(0);