From 417319a164a4cba13668eddf1de1b05b7020d75f Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 25 Sep 2026 15:28:50 -0400 Subject: [PATCH] feat(orchestrator): Start threads in known projects --- .../OrchestratorMcpService.activity.test.ts | 5 + .../src/mcp/OrchestratorMcpService.test.ts | 598 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 92 ++- ...OrchestratorMcpToolkit.integration.test.ts | 95 +++ apps/server/src/mcp/toolkits/core.test.ts | 4 + .../mcp/toolkits/orchestrator/tools.test.ts | 5 + .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 7 - .../orchestrator-mcp-server.md | 11 +- .../contracts/src/orchestratorMcp.test.ts | 11 + packages/contracts/src/orchestratorMcp.ts | 8 + .../server/orchestrationInstructions.test.ts | 5 + .../src/server/orchestrationInstructions.ts | 4 +- 13 files changed, 831 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 7c6f9d28c950..c31d973bcfb4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -13,6 +13,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "vite-plus/test"; @@ -129,6 +130,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + Path.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => @@ -194,6 +196,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + Path.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => @@ -316,6 +319,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + Path.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => { @@ -414,6 +418,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + Path.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index ee5eae449167..603ba85c55d2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -12,12 +12,16 @@ import { ProviderInstanceId, RunId, ThreadId, + type OrchestrationV2ServerCommand, type OrchestrationV2ThreadProjection, + type Project, type ServerProvider, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { @@ -41,6 +45,599 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; describe("OrchestratorMcpService", () => { + it.effect("rejects a relative project directory before creating a top-level thread", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-relative-parent"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-relative-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-relative-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-relative"), + client: undefined, + requestNamespace: "provider-session:mcp-project-relative", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-relative", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Other workspace", projectDirectory: "other" }], + clientRequestId: "create-relative-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + assert.match(error.message, /absolute path/); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect("rejects a project directory T3 does not know", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-unknown-parent"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-unknown-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-unknown-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-unknown"), + client: undefined, + requestNamespace: "provider-session:mcp-project-unknown", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-unknown", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Unknown workspace", projectDirectory: "/workspace/unknown" }], + clientRequestId: "create-unknown-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + assert.match(error.message, /not a known T3 project/); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect.each([false, true])( + "creates at the selected project root (same project: %s)", + (sameProject) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-known-parent"); + const otherProjectId = ProjectId.make(sameProject ? "project:parent" : "project:other"); + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-known-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-known-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const otherProject = { + id: otherProjectId, + title: "Other", + workspaceRoot: "/workspace/other", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + } as Project; + let deleted = false; + let parentDeleted = false; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? ({ + ...parentProjection, + thread: { + ...parentProjection.thread, + deletedAt: parentDeleted ? "2026-01-02T00:00:00.000Z" : null, + }, + } as unknown as OrchestrationV2ThreadProjection) + : ({ + thread: { + id: threadId, + projectId: otherProjectId, + title: "Other workspace", + deletedAt: null, + createdBy: "agent", + creationSource: "mcp", + worktreePath: "/workspace/other", + }, + runs: [], + } as unknown as OrchestrationV2ThreadProjection), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === "/workspace/other" + ? Option.some({ + ...otherProject, + deletedAt: deleted ? "2026-01-02T00:00:00.000Z" : null, + }) + : Option.none(), + ), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-known"), + client: undefined, + requestNamespace: "provider-session:mcp-project-known", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-known", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.createThreads(scope, { + threads: [{ title: "Other workspace", projectDirectory: "/workspace/other" }], + clientRequestId: "create-known-project", + }); + assert.equal(result.threads[0]?.title, "Other workspace"); + const command = (yield* Ref.get(dispatched)).find( + (entry) => entry.type === "thread.create", + ) as { + type: string; + projectId?: string; + worktreePath?: string | null; + branch?: string | null; + }; + assert.equal(command.type, "thread.create"); + assert.equal(command.projectId, otherProjectId); + assert.equal(command.worktreePath, null); + assert.equal(command.branch, null); + yield* service.createThreads(scope, { + threads: [{ title: "Inherited checkout" }], + clientRequestId: "inherited-checkout", + }); + const inheritedCommand = (yield* Ref.get(dispatched)).findLast( + (entry) => entry.type === "thread.create", + ); + assert.include(inheritedCommand, { + projectId: ProjectId.make("project:parent"), + branch: "main", + worktreePath: "/workspace/parent", + }); + const beforeDeletedRequest = yield* Ref.get(dispatched); + deleted = true; + const deletedResult = yield* service + .createThreads(scope, { + threads: [{ projectDirectory: "/workspace/other" }], + clientRequestId: "deleted-project", + }) + .pipe(Effect.flip); + assert.equal(deletedResult.code, "invalid_request"); + assert.deepEqual(yield* Ref.get(dispatched), beforeDeletedRequest); + parentDeleted = true; + const deletedParentResult = yield* service + .createThreads(scope, { + threads: [{ title: "Deleted parent" }], + clientRequestId: "deleted-parent", + }) + .pipe(Effect.flip); + assert.equal(deletedParentResult.code, "parent_not_active"); + assert.deepEqual(yield* Ref.get(dispatched), beforeDeletedRequest); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + + it.effect("expands a home-relative project directory", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-home-parent"); + const otherProjectId = ProjectId.make("project:home-known"); + const homeDirectory = "/home/mcp-test-user"; + const homeKnown = `${homeDirectory}/known-project`; + const dispatched = yield* Ref.make>([]); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-home-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-home-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const otherProject = { + id: otherProjectId, + title: "Home known", + workspaceRoot: homeKnown, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + } as Project; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection + : ({ + thread: { + id: threadId, + projectId: otherProjectId, + title: "Home known", + deletedAt: null, + createdBy: "agent", + creationSource: "mcp", + worktreePath: null, + }, + runs: [], + } as unknown as OrchestrationV2ThreadProjection), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command as { type: string }]).pipe( + Effect.as({} as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.succeed(workspaceRoot === homeKnown ? Option.some(otherProject) : Option.none()), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-home"), + client: undefined, + requestNamespace: "provider-session:mcp-project-home", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-home", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + yield* service.createThreads(scope, { + threads: [{ title: "Home known", projectDirectory: "~/known-project" }], + clientRequestId: "create-home-project", + }); + const command = (yield* Ref.get(dispatched)).find( + (entry) => entry.type === "thread.create", + ) as { type: string; projectId?: string; worktreePath?: string | null }; + assert.equal(command.projectId, otherProjectId); + assert.equal(command.worktreePath, null); + }).pipe( + Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))), + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + }), + ); + + it.effect("reports project lookup faults as orchestration errors", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-project-fault-parent"); + const parentProjection = { + thread: { + deletedAt: null, + id: parentThreadId, + projectId: ProjectId.make("project:parent"), + title: "Parent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/workspace/parent", + }, + runs: [ + { + id: RunId.make("run:mcp-project-fault-parent"), + ordinal: 1, + status: "running", + rootNodeId: NodeId.make("node:mcp-project-fault-root"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + subagents: [], + turnItems: [], + contextTransfers: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection), + dispatch: () => Effect.succeed({} as never), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [{ slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as unknown as ServerProvider, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([ProviderInstanceId.make("codex")]), + }), + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (workspaceRoot) => + Effect.fail( + new ProjectService.ProjectOperationError( + workspaceRoot === "/workspace/missing" + ? { + operation: "normalize-workspace", + cause: { _tag: "WorkspaceRootNotExistsError" }, + } + : { + operation: "list-projects", + cause: "simulated list failure", + }, + ), + ), + }), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-project-fault"), + client: undefined, + requestNamespace: "provider-session:mcp-project-fault", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-project-fault", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .createThreads(scope, { + threads: [{ title: "Fault", projectDirectory: "/workspace/other" }], + clientRequestId: "create-fault-project", + }) + .pipe(Effect.flip); + assert.equal(error.code, "orchestration_error"); + assert.match(error.message, /Unable to resolve project directory/); + const unavailable = yield* service + .createThreads(scope, { + threads: [{ title: "Missing root", projectDirectory: "/workspace/missing" }], + clientRequestId: "create-missing-root", + }) + .pipe(Effect.flip); + assert.equal(unavailable.code, "invalid_request"); + assert.match(unavailable.message, /not a known T3 project/); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("retries terminal acknowledgement with a fresh command id", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-ack-parent"); @@ -832,6 +1429,7 @@ describe("OrchestratorMcpService provider resolution", () => { id: parentThreadId, projectId, title: "MCP parent", + deletedAt: null, createdBy: "user", creationSource: "web", modelSelection, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 21b1f9d8262e..442ffdcc04c2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -58,6 +58,8 @@ import { type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -69,6 +71,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -195,6 +198,8 @@ export class OrchestratorMcpService extends Context.Service< OrchestratorMcpServiceShape >()("t3/mcp/OrchestratorMcpService") {} +const isProjectOperationError = Schema.is(ProjectService.ProjectOperationError); + const isThreadManagementError = Schema.is(ThreadManagementService.ThreadManagementError); function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure { @@ -224,6 +229,45 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +interface ResolvedProjectCheckout { + readonly projectId: OrchestrationV2ThreadProjection["thread"]["projectId"]; + readonly branch: string | null; + readonly worktreePath: string | null; +} + +function expandedAbsoluteProjectDirectory( + value: string, + path: Path.Path, + homeDirectory: string, +): string | null { + const expanded = expandHomePath(value.trim(), homeDirectory); + return path.isAbsolute(expanded) ? path.resolve(expanded) : null; +} + +function unknownProjectDirectory(path: string): OrchestratorMcpFailure { + return failure("invalid_request", `Project directory '${path}' is not a known T3 project.`); +} + +function isMissingWorkspaceRoot(error: ProjectService.ProjectOperationError): boolean { + if (error.operation !== "normalize-workspace") { + return false; + } + const cause = error.cause; + if (cause === null || typeof cause !== "object" || !("_tag" in cause)) { + return false; + } + return ( + cause._tag === "WorkspaceRootNotExistsError" || cause._tag === "WorkspaceRootNotDirectoryError" + ); +} + +function projectDirectoryResolveFailure(absolute: string, error: unknown): OrchestratorMcpFailure { + if (isProjectOperationError(error) && !isMissingWorkspaceRoot(error)) { + return failure("orchestration_error", `Unable to resolve project directory '${absolute}'.`); + } + return unknownProjectDirectory(absolute); +} + /** * Workspace strategy for a scheduled task created/updated over MCP: bound runs * post into the existing thread (the strategy is unused, keep root); unbound @@ -871,6 +915,7 @@ function timelineItem(input: { const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; @@ -1092,6 +1137,36 @@ const make = Effect.gen(function* () { return { parent, target, shell } as const; }); + const resolveProjectDirectory = Effect.fnUntraced(function* ( + parent: Pick, + projectDirectory: string | undefined, + ): Effect.fn.Return { + if (projectDirectory === undefined) { + return { + projectId: parent.thread.projectId, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + }; + } + const absolute = expandedAbsoluteProjectDirectory( + projectDirectory, + path, + yield* HostProcess.HomeDirectory, + ); + if (absolute === null) { + return yield* failure( + "invalid_request", + "projectDirectory must be an absolute path, or a home-relative ~/ path, to a known T3 project.", + ); + } + const project = yield* projects + .getByWorkspaceRoot(absolute) + .pipe(Effect.mapError((error) => projectDirectoryResolveFailure(absolute, error))); + if (Option.isNone(project) || project.value.deletedAt !== null) + return yield* unknownProjectDirectory(absolute); + return { projectId: project.value.id, branch: null, worktreePath: null }; + }); + const loadProviders = providerRegistry.getProviders; /** @@ -2177,6 +2252,7 @@ const make = Effect.gen(function* () { const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads"); const parentRun = ThreadManagementService.latestActiveRun(parent); if ( + parent.thread.deletedAt !== null || parentRun === undefined || parentRun.rootNodeId === null || parentRun.providerInstanceId !== scope.thread.providerInstanceId @@ -2206,6 +2282,7 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, request.interactionMode, ); + const checkout = yield* resolveProjectDirectory(parent, request.projectDirectory); const threadId = stableThreadId({ scope, requestKey: key, @@ -2229,13 +2306,13 @@ const make = Effect.gen(function* () { index, }), threadId, - projectId: parent.thread.projectId, + projectId: checkout.projectId, title, modelSelection: target.modelSelection, runtimeMode, interactionMode, - branch: parent.thread.branch, - worktreePath: parent.thread.worktreePath, + branch: checkout.branch, + worktreePath: checkout.worktreePath, }) .pipe( Effect.mapError((error) => @@ -2245,6 +2322,14 @@ const make = Effect.gen(function* () { ), ), ); + // A retried request replays the stable create, which also succeeds for a thread deleted since. + const createdProjection = yield* loadProjection(threadId); + if (createdProjection.thread.deletedAt !== null) { + return yield* failure( + "thread_not_found", + `Thread ${threadId} is no longer available.`, + ); + } if (request.prompt !== undefined) { yield* threadManagement .dispatch({ @@ -2576,6 +2661,7 @@ export const layer: Layer.Layer< OrchestratorMcpService, never, | Crypto.Crypto + | Path.Path | ThreadManagementService.ThreadManagementService | ProviderRegistry.ProviderRegistry | ProviderAdapterRegistry.ProviderAdapterRegistryV2 diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 197f11d2a951..b06d72823ee1 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -702,6 +702,15 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide( Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (directory) => + Effect.succeed( + directory === cwd + ? Option.some({ + id: ProjectId.make("project:mcp-foreign"), + deletedAt: null, + } as never) + : Option.none(), + ), getById: (id) => Effect.succeed( id === projectId @@ -2723,6 +2732,92 @@ describe("orchestrator MCP toolkit", () => { expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([ foreignThreadId, ]); + const crossProjectInput = { + clientRequestId: "create-cross-project-batch", + threads: [ + { title: "Cross-project empty", projectDirectory: cwd }, + { + title: "Cross-project prompted", + projectDirectory: cwd, + prompt: createdThreadPrompt, + }, + ], + }; + const crossProjectCall = yield* invoke("create_threads", crossProjectInput); + expect(crossProjectCall.isError).toBe(false); + const crossProjectCreated = yield* decodeCreateThreadsResult( + crossProjectCall.structuredContent, + ); + expect(crossProjectCreated.threads).toHaveLength(2); + for (const createdThread of crossProjectCreated.threads) { + const projection = yield* orchestrator.getThreadProjection(createdThread.threadId); + expect(projection.thread).toMatchObject({ + projectId: "project:mcp-foreign", + branch: null, + worktreePath: null, + lineage: { parentThreadId: null, relationshipToParent: null }, + }); + const read = yield* invoke("t3_thread_read", { threadId: createdThread.threadId }); + expect(read.structuredContent).toMatchObject({ + thread: { projectId: "project:mcp-foreign" }, + }); + } + const crossProjectRun = yield* waitForProjection( + orchestrator, + crossProjectCreated.threads[1]!.threadId, + (projection) => projection.runs.some((run) => run.status === "completed"), + ); + expect( + crossProjectRun.messages + .filter((message) => message.role === "user") + .map((message) => message.text), + ).toEqual([createdThreadPrompt]); + const repeatedCrossProjectCall = yield* invoke("create_threads", crossProjectInput); + const repeatedCrossProject = yield* decodeCreateThreadsResult( + repeatedCrossProjectCall.structuredContent, + ); + expect(repeatedCrossProject.threads.map((thread) => thread.threadId)).toEqual( + crossProjectCreated.threads.map((thread) => thread.threadId), + ); + const crossProjectIds = new Set( + crossProjectCreated.threads.map((thread) => thread.threadId), + ); + const crossProjectRecords = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).turnItems.filter( + (item) => item.type === "thread_created" && crossProjectIds.has(item.targetThreadId), + ); + expect(crossProjectRecords).toHaveLength(2); + expect(crossProjectRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + targetThreadId: crossProjectCreated.threads[0]!.threadId, + targetRunId: null, + }), + expect.objectContaining({ + targetThreadId: crossProjectCreated.threads[1]!.threadId, + targetRunId: crossProjectCreated.threads[1]!.runId, + }), + ]), + ); + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("command:mcp-cross-project:delete-empty"), + threadId: crossProjectCreated.threads[0]!.threadId, + }); + const deletedRetryCall = yield* invoke("create_threads", crossProjectInput); + expect(deletedRetryCall.isError).toBe(true); + crossProjectIds.delete(crossProjectCreated.threads[0]!.threadId); + const crossProjectList = yield* invoke("t3_thread_list", { + projectId: "project:mcp-foreign", + }); + const crossProjectListed = yield* decodeThreadListResult( + crossProjectList.structuredContent, + ); + expect(crossProjectListed.threads.map((thread) => thread.threadId)).toEqual( + expect.arrayContaining([...crossProjectIds, foreignThreadId]), + ); + const listCall = yield* invoke("t3_thread_list", { includeSubagents: false, limit: 100, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a938ba7fd999..1fdb943151c0 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -16,6 +16,7 @@ import { import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { McpAttachmentInput } from "./attachment/input.ts"; import { McpSchema, McpServer, Tool } from "effect/ai"; @@ -574,6 +575,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(Path.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), @@ -612,6 +614,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(Path.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), @@ -678,6 +681,7 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(Path.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index d7f825b6f40e..f55c95fcbbb5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -58,6 +58,11 @@ describe("orchestrator MCP tool guidance", () => { assert.include(timeoutText, "does not cancel the child"); }); + it("exposes known-project targeting only for ordinary thread creation", () => { + assert.include(CreateThreadsTool.description ?? "", "projectDirectory"); + assert.include(DelegateTaskTool.description ?? "", "always inherit this thread's project"); + }); + it("documents slim and paginated capability discovery", () => { const schema = Tool.getJsonSchema(OrchestratorCapabilitiesTool) as { readonly properties?: Readonly>; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 19d15350d0f2..627b9960377d 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -65,7 +65,7 @@ export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Delegated children always inherit this thread's project. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -167,7 +167,7 @@ const RequestSecretTool = Tool.make("request_secret", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", + "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads in known projects. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Pass projectDirectory as an absolute or ~/ path to a known T3 project to use its root checkout. Unknown or relative paths are rejected. Without it project, branch, and worktree inherit. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 19d87bd93ebb..49535b42b9cd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7470,13 +7470,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Parent node ${command.parentNodeId} is not the root of run ${command.parentRunId}.`, }); } - if (parentProjection.thread.projectId !== targetProjection.thread.projectId) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Target thread ${command.targetThreadId} belongs to another project.`, - }); - } if ( command.targetRunId !== null && !targetProjection.runs.some((candidate) => candidate.id === command.targetRunId) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index b57f038eaad5..efc14fa23c22 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -318,15 +318,18 @@ type CreateThreadsInput = { }; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; interactionMode?: "inherit" | "plan" | "default"; + projectDirectory?: string; }>; clientRequestId?: string; }; ``` -Each entry independently resolves provider, model, and modes. The new threads -inherit the parent's project, branch, and worktree path, but they have no -sub-agent lineage. Entries with a prompt immediately dispatch a run; entries -without a prompt remain idle. +Each entry independently resolves provider, model, and modes. Without +`projectDirectory`, it inherits the parent's project, branch, and worktree. +An absolute or `~/` directory must match a registered, available project root; +that selection starts at the target root with no inherited branch or worktree. +Unknown and relative paths are rejected. Entries have no subagent lineage; +a prompt immediately dispatches a run, otherwise the thread remains idle. ### `t3_thread_launch` diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index df5e1015d6c8..697de08c5fd2 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -26,6 +26,17 @@ const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSend const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); describe("orchestrator MCP contracts", () => { + it("allows projectDirectory only for top-level creation", () => { + const input = { prompt: "Inspect", projectDirectory: "/known/project" }; + expect(decodeCreateThreadsInput({ threads: [input] }).threads[0]?.projectDirectory).toBe( + input.projectDirectory, + ); + expect( + decodeDelegateTaskInput({ task: "Inspect", projectDirectory: "/other" }), + ).not.toHaveProperty("projectDirectory"); + expect(OrchestratorMcpDelegateTaskInput.fields).not.toHaveProperty("projectDirectory"); + }); + it("decodes slim and expanded capability requests", () => { expect(decodeCapabilitiesInput({})).toEqual({}); expect( diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 02aa6c9280cf..05b2a730b57e 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -48,6 +48,13 @@ const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( Schema.isMaxLength(256), ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); +const OrchestratorMcpProjectDirectory = TrimmedNonEmptyString.check( + Schema.isMaxLength(4096), +).annotate({ + description: + "Optional path of a known T3 project workspace for a new top-level thread. Absolute paths and home-relative ~/ paths are accepted. Omit to inherit this thread's project. Unknown or relative paths are rejected.", +}); + /** * OpenCode 1.15 has been observed serializing nested MCP union objects as JSON * strings. Keep the structured object as the documented form while accepting @@ -260,6 +267,7 @@ export const OrchestratorMcpCreateThreadRequest = Schema.Struct({ target: Schema.optional(OrchestratorMcpTarget), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), interactionMode: Schema.optional(OrchestratorMcpInteractionMode), + projectDirectory: Schema.optional(OrchestratorMcpProjectDirectory), }); export type OrchestratorMcpCreateThreadRequest = typeof OrchestratorMcpCreateThreadRequest.Type; diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index 612c62293e99..be7ec5421b7c 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -26,6 +26,11 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "includeModelOptions: true"); }); + it("documents known-project targeting without widening delegation", () => { + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "projectDirectory"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Delegated children always inherit"); + }); + it("documents structured schedules instead of JSON strings", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "structured object, never as JSON text"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, '"everyMs":3600000'); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index a3cf96a97b1d..f4c23aa20813 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models: call \`orchestrator_capabilities({})\` for provider summaries, pass \`providerInstanceId\` to page that provider's models with \`modelCursor\`, and request an exact \`model\` with \`includeModelOptions: true\` only when its option descriptors are needed. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models: call \`orchestrator_capabilities({})\` for provider summaries, pass \`providerInstanceId\` to page that provider's models with \`modelCursor\`, and request an exact \`model\` with \`includeModelOptions: true\` only when its option descriptors are needed. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Delegated children always inherit the caller's project. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. @@ -23,7 +23,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; a launched thread may not run with broader modes than yours. -\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. +\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` for a batch of ordinary threads: it inherits the caller's project, branch, and worktree unless an entry supplies \`projectDirectory\`, an absolute or \`~/\` path to another known project's root checkout. Unknown or relative paths are rejected. \`create_threads\` never selects a worktree; use launch for that. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. \`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.