Skip to content
Closed
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
11 changes: 10 additions & 1 deletion apps/server/src/mcp/linkOrigin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,16 @@ const layerTools = Layer.mergeAll(
Layer.provide(layerLaunches),
Layer.provide(NodeCrypto.layer),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([
{
instanceId,
enabled: true,
status: "ready",
models: [{ slug: "gpt-5", name: "GPT-5" }],
} as never,
]),
}),
),
Layer.provide(
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
Expand Down
87 changes: 80 additions & 7 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ModelSelection,
type RuntimeMode,
type OrchestrationV2ThreadShell,
type ServerProvider,
} from "@t3tools/contracts";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -27,6 +28,7 @@ import * as ServerSettings from "../../../serverSettings.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../../../vcs/VcsProcess.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
Expand All @@ -52,6 +54,28 @@ const clientLaunchHarness = (input: {
const projectDefaults: { model: ModelSelection | null } = {
model: input.overrideModel === undefined ? modelSelection : null,
};
// The registry's providers, which a test can change between launches.
const providers: Array<ServerProvider> = [
{
instanceId: ProviderInstanceId.make("claudeAgent"),
enabled: true,
status: "ready",
models: [{ slug: "claude-opus", name: "Claude Opus" }],
} as never,
{
instanceId: ProviderInstanceId.make("codex"),
enabled: true,
status: "ready",
models: [{ slug: "gpt-settings", name: "GPT Settings" }],
} as never,
// A provider that has not listed its models yet accepts any slug.
{
instanceId: ProviderInstanceId.make("opencode"),
enabled: true,
status: "ready",
models: [],
} as never,
];
const layerDependencies = Layer.mergeAll(
ServerSettings.layerTest({
...(input.defaultRuntimeMode === undefined
Expand Down Expand Up @@ -127,6 +151,9 @@ const clientLaunchHarness = (input: {
),
}),
Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.sync(() => providers),
}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)),
).pipe(
Expand All @@ -139,11 +166,11 @@ const clientLaunchHarness = (input: {
),
Layer.provideMerge(NodeServices.layer),
);
return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies };
return { projectId, modelSelection, projectDefaults, providers, dependencies: layerDependencies };
};

it.effect(
"a client launches with the project's default model and mode, capped at its ceiling",
"a client launches with the project's default model and mode, capped at its ceiling, or a model its provider advertises",
() =>
Effect.gen(function* () {
const launchWith = Effect.fn("test.launchWith")(function* (
Expand All @@ -155,6 +182,7 @@ it.effect(
readonly runtimeMode?: RuntimeMode;
readonly targeted?: false;
readonly overrideModel?: ModelSelection;
readonly modelSelection?: ModelSelection;
},
) {
const { targeted, overrideModel, ...input } = params;
Expand Down Expand Up @@ -221,6 +249,36 @@ it.effect(
overrideModel,
});
expect(fromSettings.launched[0]?.modelSelection).toEqual(overrideModel);

const advertised = yield* launchWith("full-access", "full-access", {
title: "Fix",
modelSelection: overrideModel,
});
expect(advertised.launched[0]?.modelSelection).toEqual(overrideModel);

const unadvertised = yield* launchWith("full-access", "full-access", {
title: "Fix",
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "gpt-6-astra",
},
});
expect(unadvertised.result).toMatchObject({ code: "model_unavailable" });
expect(unadvertised.launched).toHaveLength(0);

const unregistered = yield* launchWith("full-access", "full-access", {
title: "Fix",
modelSelection: { instanceId: ProviderInstanceId.make("missing"), model: "claude-opus" },
});
expect(unregistered.result).toMatchObject({ code: "provider_unavailable" });
expect(unregistered.launched).toHaveLength(0);

const unlisted = { instanceId: ProviderInstanceId.make("opencode"), model: "any-model" };
const fromUnlisted = yield* launchWith("full-access", "full-access", {
title: "Fix",
modelSelection: unlisted,
});
expect(fromUnlisted.launched[0]?.modelSelection).toEqual(unlisted);
}),
);

Expand Down Expand Up @@ -291,13 +349,14 @@ it.effect("a launch key of any length or content gives a short id and never a de
}),
);

it.effect("a retried launch replays after the project loses its default model", () =>
it.effect("a retried launch replays after its project or provider drops the model", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, modelSelection, projectDefaults, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const { projectId, modelSelection, projectDefaults, providers, dependencies } =
clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
Expand All @@ -315,6 +374,20 @@ it.effect("a retried launch replays after the project loses its default model",
launched[0]?.threadId,
launched[0]?.threadId,
]);

// An accepted launch replays even once its provider no longer lists the model.
const requested = toolkit
.handle("t3_thread_launch", {
title: "Fix",
projectId,
clientRequestId: "fix-2",
modelSelection,
})
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
yield* requested;
providers.length = 0;
expect((yield* requested).at(-1)?.result).toMatchObject({ projectId, modelSelection });
expect(launched[3]?.threadId).toBe(launched[2]?.threadId);
}),
);

Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageInt
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as ServerSettings from "../../../serverSettings.ts";
Expand Down Expand Up @@ -133,6 +134,25 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
const launchedModel =
launched === null || launched.deletedAt !== null ? undefined : launched.modelSelection;
const replaying = launchedModel !== undefined;
if (!replaying && input.modelSelection !== undefined) {
const { instanceId, model } = input.modelSelection;
const registry = yield* ProviderRegistry.ProviderRegistry;
const providers = yield* registry.getProviders;
const provider = providers.find((candidate) => candidate.instanceId === instanceId);
if (provider === undefined)
return yield* new OrchestratorMcpFailure({
code: "provider_unavailable",
message: `Provider instance ${instanceId} is not registered. No thread was created.`,
});
if (
provider.models.length > 0 &&
!provider.models.some((candidate) => candidate.slug === model)
)
return yield* new OrchestratorMcpFailure({
code: "model_unavailable",
message: `Model ${model} is not advertised by provider ${instanceId}. No thread was created. orchestrator_capabilities lists each provider's models.`,
});
}
if (!replaying && input.workspaceStrategy?.type === "existing_worktree") {
const project = yield* readProject;
if (project === undefined)
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@t3tools/contracts";
import * as FileSystem from "effect/FileSystem";
import * as DocumentText from "../../../provider/DocumentText.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ServerConfig from "../../../config.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
Expand Down Expand Up @@ -167,6 +168,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
ServerConfig.ServerConfig,
ServerSettings.ServerSettingsService,
DocumentText.DocumentText,
ProviderRegistry.ProviderRegistry,
],
})
.annotate(Tool.Destructive, true)
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,18 @@ const serveBox = (seen: Ref.Ref<Seen>, linkSession: Ref.Ref<string>) =>
),
),
Layer.provide(NodeServices.layer),
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([
{
instanceId: ProviderInstanceId.make("codex"),
enabled: true,
status: "ready",
models: [{ slug: "gpt-5", name: "GPT-5" }],
} as never,
]),
}),
),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/peer/RemoteDelegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,18 @@ const boxToolkitLayer = (thread: BoxThread) => {
Layer.provide(NodeServices.layer),
),
),
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([
{
instanceId,
enabled: true,
status: "ready",
models: [{ slug: modelSelection.model, name: "GPT-5.4" }],
} as never,
]),
}),
),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(
Expand Down
Loading