Skip to content
Open
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
90 changes: 89 additions & 1 deletion apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts";
import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import * as EffectOutbox from "./EffectOutbox.ts";
Expand Down Expand Up @@ -96,6 +97,27 @@ const adapter = {
openSession: () => Effect.die("provider execution is disabled in launch tests"),
} as ProviderAdapter.ProviderAdapterV2["Service"];

const claudeModelSelection = {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus-5-5",
} as const;
const claudeAdapter = {
instanceId: claudeModelSelection.instanceId,
driver: ProviderDriverKind.make("claudeAgent"),
getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2),
planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
openSession: () => Effect.die("provider execution is disabled in launch tests"),
} as ProviderAdapter.ProviderAdapterV2["Service"];
// A second account of the same provider, configured as its own instance.
const otherCodexModelSelection = {
instanceId: ProviderInstanceId.make("codex_work"),
model: modelSelection.model,
} as const;
const otherCodexAdapter = {
...adapter,
instanceId: otherCodexModelSelection.instanceId,
} as ProviderAdapter.ProviderAdapterV2["Service"];

interface HarnessOptions {
readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>;
readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"];
Expand All @@ -112,7 +134,11 @@ interface HarnessOptions {

function makeHarness(options: HarnessOptions = {}) {
const layerDatabase = SqlitePersistence.layerMemory;
const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]);
const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
adapter,
claudeAdapter,
otherCodexAdapter,
]);
const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
{ name: "thread-launch" },
layerRegistry,
Expand Down Expand Up @@ -365,6 +391,68 @@ it.effect.each(
},
);

it.effect.each([
{ switchedTo: "another provider", selection: claudeModelSelection },
{ switchedTo: "another account of the same provider", selection: otherCodexModelSelection },
])("runs a thread-bound automation on $switchedTo the thread uses now", ({ selection }) => {
const harness = makeHarness({
serverSettings: {
providerInstances: {
[otherCodexModelSelection.instanceId]: { driver: adapter.driver, config: {} },
},
},
});
const layerScheduledTasks = ScheduledTasks.layer.pipe(
Layer.provide(
Layer.mergeAll(
harness.layer,
NodeCrypto.layer,
Scheduler.layer,
Layer.mock(SecretRequests.SecretRequests)({}),
),
),
);
return Effect.gen(function* () {
const tasks = yield* ScheduledTasks.ScheduledTaskService;
const launches = yield* ThreadLaunch.ThreadLaunchService;
const threads = yield* ThreadManagement.ThreadManagementService;
const launched = yield* launches.launch(
launchInput({ command: "command:switched", thread: "thread:switched" }),
);
// The task is created while the thread is on Codex...
const { task } = yield* tasks.upsert({
id: ScheduledTaskId.make("scheduled-task:switched"),
title: "Hourly check",
prompt: "Check on the delegated workers.",
enabled: false,
schedule: { type: "interval", everyMs: 3_600_000 },
projectId,
threadId: launched.threadId,
workspaceStrategy: { type: "root" },
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
createdBy: "agent",
creationSource: "mcp",
});
// ...and the user then switches the thread away from that instance.
yield* threads.dispatch({
type: "thread.model-selection.set",
commandId: CommandId.make("command:switch-selection"),
threadId: launched.threadId,
modelSelection: selection,
});

const result = yield* tasks.runNow({ id: task.id });
assert.equal(result.task.lastRunStatus, "succeeded");
const projection = yield* threads.getThreadProjection(launched.threadId);
const message = projection.messages.find((item) => item.scheduledTaskId === task.id);
const run = projection.runs.find((item) => item.id === message?.runId);
assert.deepStrictEqual(run?.modelSelection, selection);
assert.deepStrictEqual(projection.thread.modelSelection, selection);
}).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerScheduledTasks)));
});

it.effect("retains automation and sender attribution while a message waits in the queue", () => {
const harness = makeHarness({ runSetup: () => Effect.never });
return Effect.gen(function* () {
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ export const layer = Layer.effect(
}),
)
: yield* Effect.exit(
// No modelSelection: the run follows the thread's current
// provider and model, not the one the task was created with.
threadManagement.sendToThread({
projectId: active.projectId,
commandId,
Expand All @@ -822,7 +824,6 @@ export const layer = Layer.effect(
scheduledTaskId: active.id,
text: prompt,
attachments: [],
modelSelection: active.modelSelection,
// Scheduled prompts must not interrupt tools in the bound thread.
mode: "queue",
createdBy: active.createdBy,
Expand Down
Loading