From 43ef3a119d75d53f20820aefefea89c92172db0c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 04:53:54 -0700 Subject: [PATCH 1/2] feat(server): agents list snoozed threads across all projects in one call Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 65 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 7 +- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../ThreadManagementService.ts | 56 +++++++++------- packages/contracts/src/orchestratorMcp.ts | 7 +- 5 files changed, 109 insertions(+), 28 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index ead5229f86502..4996044d7cdc7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2694,6 +2694,71 @@ describe("OrchestratorMcpService thread lookup", () => { ); }); +describe("OrchestratorMcpService thread list", () => { + const client: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:thread-list"), + requestNamespace: "client:thread-list", + thread: undefined, + client: { sessionId: "thread-list", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const shell = (id: string, projectId: string, snoozed: boolean) => ({ + ...liveThreadShell(ThreadId.make(id)), + projectId: ProjectId.make(projectId), + status: "completed" as const, + snoozedUntil: snoozed ? DateTime.makeUnsafe("2099-01-01T00:00:00Z") : null, + }); + const shells = [ + shell("thread:a-snoozed", "project:a", true), + shell("thread:a-awake", "project:a", false), + shell("thread:b-snoozed", "project:b", true), + ]; + const service = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + listProjectThreads: (input) => + Effect.succeed( + shells.filter( + (thread) => input.projectId === undefined || thread.projectId === input.projectId, + ), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ); + + it.effect("lists snoozed threads across every project with allProjects", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const listed = yield* mcp.listThreads(client, { + allProjects: true, + projectId: ProjectId.make("project:a"), + snoozed: true, + }); + assert.equal(listed.projectId, null); + assert.deepStrictEqual( + listed.threads.map((thread) => [thread.threadId, thread.projectId]), + [ + ["thread:a-snoozed", "project:a"], + ["thread:b-snoozed", "project:b"], + ], + ); + }).pipe(Effect.provide(service)), + ); +}); + describe("OrchestratorMcpService thread stop", () => { const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 3681c45789093..6e5b8d06db251 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -755,6 +755,7 @@ function listItemFromShell( ): OrchestratorMcpThreadListItem { return { threadId: shell.id, + projectId: shell.projectId, title: shell.title, link: formatThreadLink({ environmentId, threadId: shell.id }), createdBy: shell.createdBy, @@ -2764,10 +2765,12 @@ const make = Effect.gen(function* () { ), listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { const { parent } = yield* loadCaller(scope); - const projectId = yield* resolveProjectTarget(parent, input.projectId); + const projectId = input.allProjects + ? null + : yield* resolveProjectTarget(parent, input.projectId); const projectThreads = yield* threadManagement .listProjectThreads({ - projectId, + projectId: projectId ?? undefined, includeSubagents: input.includeSubagents !== false, }) .pipe( diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 10cf6e2106efc..839e439a3762b 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -222,7 +222,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List threads in a project, newest first. Omit `projectId` for yours. Filter by status, title, settled, or snoozed. Page with the cursor.", + "List threads in a project, newest first. Omit `projectId` for yours, or pass `allProjects`. Filter by status, title, settled, or snoozed. Page with the cursor.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 615ea352f25af..cfbbd4a68a51d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -279,12 +279,14 @@ export class ThreadManagementProjectionLoadError extends Schema.TaggedError()( "ThreadManagementProjectThreadsListError", { - projectId: ProjectId, + projectId: Schema.optional(ProjectId), cause: Schema.Defect(), }, ) { override get message(): string { - return `Unable to list threads in project ${this.projectId}.`; + return this.projectId === undefined + ? "Unable to list threads." + : `Unable to list threads in project ${this.projectId}.`; } } @@ -404,8 +406,9 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; + /** Omit `projectId` to list every project's threads. */ readonly listProjectThreads: (input: { - readonly projectId: ProjectId; + readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( @@ -736,27 +739,32 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator.getShellSnapshot({ projectId: input.projectId, location: "active" }).pipe( - Effect.mapError( - (cause) => - new ThreadManagementProjectThreadsListError({ - projectId: input.projectId, - cause, - }), - ), - Effect.map((snapshot) => - snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || - right.id.localeCompare(left.id), - ), - ), - ); + orchestrator + .getShellSnapshot({ + location: "active", + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + }) + .pipe( + Effect.mapError( + (cause) => + new ThreadManagementProjectThreadsListError({ + projectId: input.projectId, + cause, + }), + ), + Effect.map((snapshot) => + snapshot.threads + .filter( + (thread) => + input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ), + ), + ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 9b9100287f9c6..7e1c0d0fb3625 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -330,6 +330,9 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( export const OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, + allProjects: Schema.optional( + Schema.Boolean.annotate({ description: "List every project's threads. Ignores `projectId`." }), + ), statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), @@ -353,6 +356,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, + projectId: ProjectId, title: Schema.String, link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, @@ -378,7 +382,8 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem.Type; export const OrchestratorMcpThreadListResult = Schema.Struct({ - projectId: ProjectId, + /** null with `allProjects`. */ + projectId: Schema.NullOr(ProjectId), currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), From a36537d7d77218d20406af9e0c8d9dc606eec407 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 05:00:12 -0700 Subject: [PATCH 2/2] fix(server): keep thread list tool text inside the context budget Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 839e439a3762b..413ab984211f8 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -222,7 +222,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List threads in a project, newest first. Omit `projectId` for yours, or pass `allProjects`. Filter by status, title, settled, or snoozed. Page with the cursor.", + "List threads newest first, in one project or with `allProjects`. Page with `cursor`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index fdc39bc78bc0a..e67da4d775a89 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 5687, - "mcpToolListJsonBytes": 45861 + "mcpToolListJsonBytes": 45842 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7e1c0d0fb3625..1474a5d6e87b6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -330,9 +330,7 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( export const OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, - allProjects: Schema.optional( - Schema.Boolean.annotate({ description: "List every project's threads. Ignores `projectId`." }), - ), + allProjects: Schema.optional(Schema.Boolean), statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ),