Skip to content

Commit d51136a

Browse files
authored
Merge pull request #26 from samnab/fix/api-e2e-findings
fix(mcp): archived thread list and scheduled-task default model
2 parents c18a12b + f7f38bf commit d51136a

4 files changed

Lines changed: 114 additions & 12 deletions

File tree

‎apps/server/src/mcp/OrchestratorMcpService.activity.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import * as DateTime from "effect/DateTime";
1717
import * as Effect from "effect/Effect";
1818
import * as Layer from "effect/Layer";
19+
import * as Option from "effect/Option";
1920
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2021
import * as NodeServices from "@effect/platform-node/NodeServices";
2122
import { expect, it } from "vite-plus/test";
@@ -708,3 +709,57 @@ it("readThread returns canonical speech with bounded text continuation and no ex
708709
).toBe(true);
709710
}).pipe(Effect.provide(layer), Effect.runPromise);
710711
});
712+
713+
it("scheduleTask from an MCP client uses the environment default model when the project has none", async () => {
714+
const globalDefault = {
715+
instanceId: ProviderInstanceId.make("claudeAgent"),
716+
model: "claude-haiku",
717+
};
718+
const upserted: Array<unknown> = [];
719+
const layer = layerOrchestratorService.pipe(
720+
Layer.provide(
721+
Layer.mergeAll(
722+
unusedDelegationPolicyLayer,
723+
ServerSettings.layerTest({ defaultModelSelection: globalDefault }),
724+
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
725+
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
726+
Layer.mock(ProjectService.ProjectService)({
727+
getById: () =>
728+
Effect.succeed(Option.some({ id: projectId, defaultModelSelection: null } as never)),
729+
}),
730+
Layer.mock(SecretRequests.SecretRequests)({}),
731+
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
732+
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
733+
upsert: (input) =>
734+
Effect.sync(() => upserted.push(input.modelSelection)).pipe(
735+
Effect.andThen(Effect.die("upsert recorded")),
736+
),
737+
}),
738+
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
739+
list: () => Effect.succeed([]),
740+
}),
741+
NodeCrypto.layer,
742+
),
743+
),
744+
);
745+
746+
await Effect.gen(function* () {
747+
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
748+
yield* service
749+
.scheduleTask(
750+
{
751+
...makeScope(),
752+
thread: undefined,
753+
client: { sessionId: "session-1", label: "Claude Code", access: "auto" },
754+
},
755+
{
756+
projectId,
757+
prompt: "check in later",
758+
enabled: false,
759+
schedule: { type: "interval", everyMs: 3_600_000 },
760+
},
761+
)
762+
.pipe(Effect.exit);
763+
expect(upserted).toEqual([globalDefault]);
764+
}).pipe(Effect.provide(layer), Effect.runPromise);
765+
});

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from "@t3tools/contracts";
6666
import { getProviderOptionCurrentValue } from "@t3tools/shared/model";
6767
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
68+
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
6869
import { formatThreadLink } from "@t3tools/shared/threadLinks";
6970
import {
7071
normalizeThreadPullRequestKey,
@@ -1024,17 +1025,25 @@ const make = Effect.gen(function* () {
10241025
),
10251026
);
10261027

1027-
const projectDefaultModelSelection = (
1028-
project: Effect.Success<ReturnType<typeof requireProject>>,
1029-
) =>
1030-
project.defaultModelSelection === null
1031-
? Effect.fail(
1032-
failure(
1033-
"invalid_request",
1034-
`Project ${project.id} has no default model, so this caller cannot pick one for it.`,
1035-
),
1036-
)
1037-
: Effect.succeed(project.defaultModelSelection);
1028+
/** The project's effective default: project override, then the environment default. */
1029+
const projectDefaultModelSelection = Effect.fn(
1030+
"OrchestratorMcpService.projectDefaultModelSelection",
1031+
)(function* (project: Effect.Success<ReturnType<typeof requireProject>>) {
1032+
const settings = yield* serverSettings.getSettings.pipe(
1033+
Effect.mapError((error) =>
1034+
failure("orchestration_error", `Unable to read server settings: ${error.message}`),
1035+
),
1036+
);
1037+
const modelSelection = resolveProjectSettings(settings, project.id, project).settings
1038+
.defaultModelSelection;
1039+
if (modelSelection === null) {
1040+
return yield* failure(
1041+
"invalid_request",
1042+
`Project ${project.id} has no default model, so this caller cannot pick one for it.`,
1043+
);
1044+
}
1045+
return modelSelection;
1046+
});
10381047
const secretRequests = yield* SecretRequests.SecretRequests;
10391048

10401049
const requireCapability = (scope: McpInvocationScope) =>

‎apps/server/src/orchestration-v2/ThreadManagementService.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,40 @@ it.effect.each([
596596
expect(dispatched).toEqual(settles ? ["thread.settle"] : []);
597597
}),
598598
);
599+
600+
it.effect("listProjectThreads returns archived threads for the archive location", () =>
601+
Effect.gen(function* () {
602+
const threadId = ThreadId.make("thread:thread-management:archived");
603+
const archived = {
604+
id: threadId,
605+
updatedAt: DateTime.makeUnsafe(0),
606+
lineage: { relationshipToParent: null },
607+
};
608+
const layerTest = ThreadManagementService.layer.pipe(
609+
Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
610+
Layer.provide(
611+
Layer.mock(Orchestrator.OrchestratorV2)({
612+
// Mirrors ProjectionStore: an archive snapshot fills only `archivedThreads`.
613+
getShellSnapshot: (input) =>
614+
Effect.succeed(
615+
(input?.location === "archive"
616+
? { threads: [], archivedThreads: [archived] }
617+
: { threads: [], archivedThreads: [] }) as never,
618+
),
619+
}),
620+
),
621+
Layer.provide(ServerSettings.layerTest()),
622+
Layer.provide(layerReceiptsTest),
623+
);
624+
const service = yield* ThreadManagementService.ThreadManagementService.pipe(
625+
Effect.provide(layerTest),
626+
);
627+
628+
const threads = yield* service.listProjectThreads({
629+
location: "archive",
630+
includeSubagents: false,
631+
});
632+
633+
expect(threads.map((thread) => thread.id)).toEqual([threadId]);
634+
}),
635+
);

‎apps/server/src/orchestration-v2/ThreadManagementService.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,8 +767,9 @@ const make = Effect.gen(function* () {
767767
cause,
768768
}),
769769
),
770+
// The snapshot keeps archived rows apart; `location` already picked which side is filled.
770771
Effect.map((snapshot) =>
771-
snapshot.threads
772+
[...snapshot.threads, ...snapshot.archivedThreads]
772773
.filter(
773774
(thread) =>
774775
input.includeSubagents || thread.lineage.relationshipToParent !== "subagent",

0 commit comments

Comments
 (0)