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
93 changes: 93 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RunId,
ScheduledTaskId,
ThreadId,
TurnItemId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -205,6 +206,98 @@ it("readThread preserves caller access errors and prefers activity-run status ov
}).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<ThreadManagementService.ThreadManagementService["Service"]>),
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
list: () => Effect.succeed([]),
} satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
NodeCrypto.layer,
),
),
);

await Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
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,
});
textOffset += text.length;
expect(result.items[0]).toMatchObject({
text,
textTruncated: index < expected.length - 1,
nextTextOffset: index < expected.length - 1 ? textOffset : null,
});
}
}
}).pipe(Effect.provide(layer), Effect.runPromise);
});

it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => {
const parentProjection = {
thread: baseThread({
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -944,6 +944,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;
Expand All @@ -952,7 +961,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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading