Skip to content
Open
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
211 changes: 211 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,3 +744,214 @@ it("readThread exposes the stored sender thread only for thread-sent messages",
expect(result.items[1]).not.toHaveProperty("senderThreadId");
}).pipe(Effect.provide(layer), Effect.runPromise);
});

const readThreadId = ThreadId.make("thread-mcp-orchestrator-read");

function assistantRow(position: number, text: string) {
return {
position,
visibility: "local",
sourceThreadId: readThreadId,
sourceItemId: `item-${position}`,
item: {
id: `item-${position}`,
type: "assistant_message",
runId: "run-read-1",
messageId: `message-${position}`,
status: "completed",
title: null,
text,
createdAt: now,
updatedAt: now,
},
};
}

function readThreadLayer(input: {
readonly rows: ReadonlyArray<{
readonly position: number;
readonly sourceItemId: string;
readonly item: { readonly type: string };
}>;
readonly runCount?: number;
}) {
const projection = {
thread: baseThread({
threadId: readThreadId,
title: "Read",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
runs: Array.from({ length: input.runCount ?? 1 }, (_, index) =>
makeRun({ id: RunId.make(`run-read-${index + 1}`), ordinal: index + 1, status: "completed" }),
),
visibleTurnItems: input.rows,
runtimeRequests: [],
messages: [],
contextTransfers: [],
subagents: [],
updatedAt: now,
} as unknown as OrchestrationV2ThreadProjection;
return layerOrchestratorService.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagementService.ThreadManagementService)({
getThreadRecords: () => Effect.succeed(projection),
getThreadShell: () =>
Effect.succeed(projection.thread as unknown as OrchestrationV2ThreadShell),
getTimelinePage: (_threadId, options) => {
// Match ProjectionStore: itemId bypasses the message-only default view.
const matching = input.rows.filter((row) =>
options.itemId === undefined
? row.position > (options.afterPosition ?? -1) &&
(options.view === "activity" ||
["user_message", "assistant_message", "proposed_plan"].includes(row.item.type))
: row.sourceItemId === options.itemId,
);
return Effect.succeed({
items: matching.slice(0, options.limit),
totalItems: input.rows.length,
hasMore: matching.length > options.limit,
} as never);
},
getProjectThreadRecords: () => Effect.succeed(projection),
} satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
list: () => Effect.succeed([]),
}),
NodeCrypto.layer,
),
),
);
}

it("readThread defaults to 20 items and the 5 newest runs on the first page", async () => {
const rows = Array.from({ length: 30 }, (_, position) => assistantRow(position, "reply"));
await Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
const first = yield* service.readThread(makeScope(), { threadId: readThreadId });
expect(first.items).toHaveLength(20);
expect(first.hasMore).toBe(true);
expect(first.recentRuns.map((run) => run.ordinal)).toEqual([8, 7, 6, 5, 4]);

const next = yield* service.readThread(makeScope(), {
threadId: readThreadId,
afterPosition: first.nextPosition!,
});
expect(next.items.map((item) => item.position)).toEqual(
rows.slice(20).map((row) => row.position),
);
expect(next.hasMore).toBe(false);
expect(next.recentRuns).toEqual([]);
const withRuns = yield* service.readThread(makeScope(), {
threadId: readThreadId,
afterPosition: first.nextPosition!,
runLimit: 2,
});
expect(withRuns.recentRuns.map((run) => run.ordinal)).toEqual([8, 7]);
}).pipe(Effect.provide(readThreadLayer({ rows, runCount: 8 })), Effect.runPromise);
});

it("readThread previews tool output and long messages, and continues one item on request", async () => {
const toolOutput = "tool-output-line\n".repeat(200);
const longReply = "r".repeat(10_000);
const toolRow = {
...assistantRow(0, ""),
sourceItemId: "item-tool",
item: {
id: "item-tool",
type: "dynamic_tool",
runId: "run-read-1",
status: "completed",
title: null,
toolName: "fetch",
input: { url: "https://example.com" },
output: toolOutput,
createdAt: now,
updatedAt: now,
},
};
const rows = [toolRow, assistantRow(1, longReply)];
await Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
const messages = yield* service.readThread(makeScope(), { threadId: readThreadId });
expect(messages.items.map((item) => item.itemId)).toEqual(["item-1"]);
const page = yield* service.readThread(makeScope(), {
threadId: readThreadId,
view: "activity",
});
const [tool, reply] = page.items;
expect(tool).toMatchObject({ textTruncated: true, nextTextOffset: 600 });
expect(tool!.text).toHaveLength(600);
expect(reply).toMatchObject({ textTruncated: true, nextTextOffset: 4_000 });
expect(reply!.text).toBe(longReply.slice(0, 4_000));

const rest = yield* service.readThread(makeScope(), {
threadId: readThreadId,
itemId: tool!.itemId,
textOffset: tool!.nextTextOffset,
maxCharsPerItem: 50_000,
});
expect(rest.items).toHaveLength(1);
expect(rest.items[0]).toMatchObject({ textTruncated: false });
expect(rest.items[0]).not.toHaveProperty("nextTextOffset");
expect(JSON.parse(tool!.text! + rest.items[0]!.text!)).toEqual({
toolName: "fetch",
input: { url: "https://example.com" },
output: toolOutput,
});

const explicit = yield* service.readThread(makeScope(), {
threadId: readThreadId,
view: "activity",
maxCharsPerItem: 1_000,
});
expect(explicit.items.map((item) => item.text?.length)).toEqual([1_000, 1_000]);
}).pipe(Effect.provide(readThreadLayer({ rows })), Effect.runPromise);
});

it("readThread omits item metadata that is empty or implied by the read thread", async () => {
const inherited = {
...assistantRow(1, "from the parent"),
visibility: "inherited",
sourceThreadId: parentThreadId,
};
const rows = [
assistantRow(0, "reply"),
{ ...inherited, item: { ...inherited.item, runId: null } },
];
await Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
const page = yield* service.readThread(makeScope(), { threadId: readThreadId });
expect(page.items).toEqual([
{
position: 0,
itemId: "item-0",
runId: "run-read-1",
messageId: "message-0",
type: "assistant_message",
status: "completed",
text: "reply",
textTruncated: false,
updatedAt: "2026-08-04T12:00:00.000Z",
},
{
position: 1,
visibility: "inherited",
sourceThreadId: parentThreadId,
itemId: "item-1",
messageId: "message-1",
type: "assistant_message",
status: "completed",
text: "from the parent",
textTruncated: false,
updatedAt: "2026-08-04T12:00:00.000Z",
},
]);
}).pipe(Effect.provide(readThreadLayer({ rows })), Effect.runPromise);
});
94 changes: 61 additions & 33 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,21 @@ const TASK_WAKE_EVENTS = [
] as const;
const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
const DEFAULT_THREAD_LIST_LIMIT = 50;
const DEFAULT_THREAD_READ_LIMIT = 50;
const DEFAULT_THREAD_RUN_LIMIT = 10;
const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000;
const DEFAULT_THREAD_READ_LIMIT = 20;
const DEFAULT_THREAD_RUN_LIMIT = 5;
// Agents pay for every character they read back, so tool output is only a
// preview by default; an explicit maxCharsPerItem overrides both budgets.
const DEFAULT_THREAD_MESSAGE_MAX_CHARS = 4_000;
const DEFAULT_THREAD_TOOL_MAX_CHARS = 600;
const TOOL_OUTPUT_ITEM_TYPES: ReadonlySet<OrchestrationV2TurnItem["type"]> = new Set([
"dynamic_tool",
"command_execution",
"file_change",
"file_search",
"web_search",
"checkpoint",
"user_input_request",
]);

interface ResolvedTarget {
readonly modelSelection: ModelSelection;
Expand Down Expand Up @@ -530,7 +542,7 @@ function pageIncludesTerminalTaskResult(input: {
OrchestrationV2ThreadProjection,
"thread" | "runs" | "contextTransfers" | "messages" | "turnItems"
>;
readonly maxChars: number;
readonly maxCharsPerItem: number | undefined;
}): boolean {
const transfer = taskResultTransfer(input.parent, input.target, input.task);
if (transfer === undefined) return false;
Expand All @@ -553,7 +565,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 <= itemTextBudget(row.item, input.maxCharsPerItem);
});
}

Expand Down Expand Up @@ -933,44 +945,56 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {
}
}

function itemTextBudget(item: OrchestrationV2TurnItem, maxCharsPerItem: number | undefined) {
return (
maxCharsPerItem ??
(TOOL_OUTPUT_ITEM_TYPES.has(item.type)
? DEFAULT_THREAD_TOOL_MAX_CHARS
: DEFAULT_THREAD_MESSAGE_MAX_CHARS)
);
}

function timelineItem(input: {
readonly threadId: ThreadId;
readonly row: OrchestrationV2ThreadProjection["visibleTurnItems"][number];
readonly maxChars: number;
readonly maxCharsPerItem: number | undefined;
readonly textOffset?: number;
readonly messagesByThreadId: ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection["messages"]>;
}): OrchestratorMcpThreadTimelineItem {
const text = turnItemText(input.row.item);
const { row } = input;
const text = turnItemText(row.item);
const offset = input.textOffset ?? 0;
const end = offset + input.maxChars;
const end = offset + itemTextBudget(row.item, input.maxCharsPerItem);
const textTruncated = text !== null && text.length > end;
const messageId =
input.row.item.type === "user_message" || input.row.item.type === "assistant_message"
? input.row.item.messageId
row.item.type === "user_message" || row.item.type === "assistant_message"
? row.item.messageId
: null;
const message =
messageId === null
? undefined
: input.messagesByThreadId
.get(input.row.sourceThreadId)
.get(row.sourceThreadId)
?.find((candidate) => candidate.id === messageId);
return {
position: input.row.position,
visibility: input.row.visibility,
sourceThreadId: input.row.sourceThreadId,
itemId: input.row.sourceItemId,
...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}),
runId: input.row.item.runId,
messageId,
createdBy: message?.createdBy ?? null,
creationSource: message?.creationSource ?? null,
position: row.position,
...(row.visibility === "local" ? {} : { visibility: row.visibility }),
...(row.sourceThreadId === input.threadId ? {} : { sourceThreadId: row.sourceThreadId }),
itemId: row.sourceItemId,
...(row.item.type === "subagent" ? { subagentId: row.item.subagentId } : {}),
...(row.item.runId === null ? {} : { runId: row.item.runId }),
...(messageId === null ? {} : { messageId }),
...(message === undefined
? {}
: { createdBy: message.createdBy, creationSource: message.creationSource }),
...(message?.senderThreadId === undefined ? {} : { senderThreadId: message.senderThreadId }),
type: input.row.item.type,
status: input.row.item.status,
title: input.row.item.title,
text: text === null ? null : text.slice(offset, end),
type: row.item.type,
status: row.item.status,
...(row.item.title === null ? {} : { title: row.item.title }),
...(text === null ? {} : { text: text.slice(offset, end) }),
textTruncated,
nextTextOffset: textTruncated ? end : null,
updatedAt: DateTime.formatIso(input.row.item.updatedAt),
...(textTruncated ? { nextTextOffset: end } : {}),
updatedAt: DateTime.formatIso(row.item.updatedAt),
};
}

Expand Down Expand Up @@ -2611,7 +2635,6 @@ const make = Effect.gen(function* () {
const view = input.view ?? "messages";
const afterPosition = input.afterPosition ?? -1;
const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;
const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS;
const timeline = yield* threadManagement
.getTimelinePage(input.threadId, {
afterPosition,
Expand Down Expand Up @@ -2667,7 +2690,7 @@ const make = Effect.gen(function* () {
page,
task,
target: { ...target, ...resultRecords },
maxChars,
maxCharsPerItem: input.maxCharsPerItem,
})
) {
yield* readTask(
Expand All @@ -2682,14 +2705,19 @@ const make = Effect.gen(function* () {
}
return {
thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
recentRuns: target.runs
.toSorted((left, right) => right.ordinal - left.ordinal)
.slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
.map(threadRun),
// The first page already carried the runs; later pages repeat them only on request.
recentRuns:
input.afterPosition !== undefined && input.runLimit === undefined
? []
: target.runs
.toSorted((left, right) => right.ordinal - left.ordinal)
.slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
.map(threadRun),
items: page.map((row) =>
timelineItem({
threadId: target.thread.id,
row,
maxChars,
maxCharsPerItem: input.maxCharsPerItem,
messagesByThreadId,
...(input.itemId === undefined ? {} : { textOffset: input.textOffset ?? 0 }),
}),
Expand Down
Loading
Loading