diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e0bb1aa96e2f3..862263b3a4c6f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3886,6 +3886,13 @@ describe("OrchestratorMcpService thread list", () => { shell("thread:a-snoozed", "project:a", true), shell("thread:a-awake", "project:a", false), shell("thread:b-snoozed", "project:b", true), + { + ...shell("thread:a-awaiting-command", "project:a", false), + activityRunStatus: null, + pendingBackgroundTasks: [ + { taskId: "sleep-600", kind: "command" as const, awaited: true as const }, + ], + }, ]; const service = layerOrchestratorService.pipe( Layer.provide( @@ -3932,6 +3939,20 @@ describe("OrchestratorMcpService thread list", () => { }).pipe(Effect.provide(service)), ); + it.effect("counts a thread holding awaited background work as live in the status filter", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const listed = yield* mcp.listThreads(client, { + projectId: ProjectId.make("project:a"), + statuses: ["preparing", "queued", "starting", "running", "waiting"], + }); + assert.deepStrictEqual( + listed.threads.map((thread) => thread.threadId), + ["thread:a-awaiting-command"], + ); + }).pipe(Effect.provide(service)), + ); + it.effect("points a caller outside any thread at allProjects when it omits projectId", () => Effect.gen(function* () { const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 960253adc3982..e03e0209f6812 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -68,6 +68,7 @@ import { SECRET_REQUEST_UNSAVED_NOTE, } from "@t3tools/contracts"; import { getProviderOptionCurrentValue, withLegacyUltracodeEffort } from "@t3tools/shared/model"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { @@ -814,6 +815,14 @@ function threadPullRequests( })); } +// Awaited background work keeps a thread live after its turn ends, as auto-settle and the sidebar treat it. +function listedThreadStatus(shell: OrchestrationV2ThreadShell) { + if (shell.activityRunStatus != null) return shell.activityRunStatus; + return backgroundWorkHoldsCompletion(shell.pendingBackgroundTasks ?? []) + ? "waiting" + : shell.status; +} + function listItemFromShell( scope: McpInvocationScope, shell: OrchestrationV2ThreadShell, @@ -826,7 +835,7 @@ function listItemFromShell( link: threadLinkFor(scope, shell.id, shell.title), createdBy: shell.createdBy, creationSource: shell.creationSource, - status: shell.activityRunStatus ?? shell.status, + status: listedThreadStatus(shell), latestRunId: shell.latestRunId, providerInstanceId: shell.modelSelection.instanceId, model: shell.modelSelection.model, @@ -2978,9 +2987,7 @@ const make = Effect.gen(function* () { : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); const filtered = projectThreads - .filter( - (thread) => statuses === null || statuses.has(thread.activityRunStatus ?? thread.status), - ) + .filter((thread) => statuses === null || statuses.has(listedThreadStatus(thread))) .filter( (thread) => input.settled === undefined || threadSettlement(thread).settled === input.settled,