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
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,11 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)(
const activeTurnId = TurnId.make("turn-started-after-original-send");
const originalTurnId = TurnId.make("turn-from-original-send");
const sent = yield* Deferred.make<ProviderSendTurnInput>();
const modelSelection = {
instanceId: providerInstanceId,
model: "gpt-5",
options: [{ id: "reasoningEffort", value: "high" }],
};

yield* Effect.gen(function* () {
const engine = yield* OrchestrationEngine.OrchestrationEngineService;
Expand All @@ -384,7 +389,7 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)(
projectId,
title: "Restart continuation",
workspaceRoot: "/tmp/startup-orphan-project",
defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
defaultModelSelection: modelSelection,
createdAt,
});
yield* engine.dispatch({
Expand All @@ -393,7 +398,7 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)(
threadId,
projectId,
title: "Newer running turn",
modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
modelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
Expand Down Expand Up @@ -422,7 +427,10 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)(
providerInstanceId,
status: "running",
resumeCursor,
runtimePayload: { activeTurnId: originalTurnId },
runtimePayload: {
activeTurnId: originalTurnId,
modelSelection,
},
});
if (restart === "marked remote update") {
assert.deepStrictEqual(
Expand Down Expand Up @@ -469,6 +477,7 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)(
assert.deepStrictEqual(yield* Deferred.await(sent), {
threadId,
continuation: true,
modelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
});
}).pipe(
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,26 @@ describe("buildTurnStartParams", () => {
});
});

it.effect("preserves a non-default reasoning effort in collaboration mode", () =>
Effect.gen(function* () {
const params = yield* buildTurnStartParams({
threadId: "provider-thread-1",
runtimeMode: "full-access",
prompt: "Continue the task",
model: "gpt-5.3-codex",
effort: "high",
interactionMode: "default",
});

NodeAssert.equal(params.effort, "high");
NodeAssert.equal(params.collaborationMode?.settings.reasoning_effort, "high");
NodeAssert.match(
params.collaborationMode?.settings.developer_instructions ?? "",
/with high reasoning effort/,
);
}),
);

it("reports the same fallback model and effort in settings and instructions", () => {
const params = Effect.runSync(
buildTurnStartParams({
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2809,17 +2809,35 @@ routing.layer("ProviderServiceLive routing", (it) => {
}),
);

it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>
it.effect("recovers stale sessions using persisted cwd and legacy model selection", () =>
Effect.gen(function* () {
const provider = yield* ProviderService.ProviderService;
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
const cwd = fixtureCwd("project-send-turn");
const modelSelection = createModelSelection(codexInstanceId, "gpt-5.4", [
{ id: "reasoningEffort", value: "high" },
]);

const initial = yield* provider.startSession(asThreadId("thread-1"), {
provider: ProviderDriverKind.make("codex"),
providerInstanceId: codexInstanceId,
threadId: asThreadId("thread-1"),
cwd: fixtureCwd("project-send-turn"),
cwd,
modelSelection,
runtimeMode: "full-access",
});
const binding = Option.getOrThrow(yield* directory.getBinding(initial.threadId));
yield* directory.upsert({
...binding,
runtimePayload: {
...(binding.runtimePayload as Record<string, unknown>),
modelSelection: {
provider: "codex",
model: modelSelection.model,
options: modelSelection.options,
},
},
});

yield* routing.codex.stopAll();
routing.codex.startSession.mockClear();
Expand All @@ -2838,11 +2856,13 @@ routing.layer("ProviderServiceLive routing", (it) => {
const startPayload = resumedStartInput as {
provider?: string;
cwd?: string;
modelSelection?: unknown;
resumeCursor?: unknown;
threadId?: string;
};
assert.equal(startPayload.provider, "codex");
assert.equal(startPayload.cwd, fixtureCwd("project-send-turn"));
assert.equal(startPayload.cwd, cwd);
assert.deepEqual(startPayload.modelSelection, modelSelection);
assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
assert.equal(startPayload.threadId, initial.threadId);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
const isModelSelection = Schema.is(ModelSelection);
const decodeModelSelectionOption = Schema.decodeUnknownOption(ModelSelection);
const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

interface SnapShotPromptAccessibilityNode {
Expand Down Expand Up @@ -412,7 +412,7 @@ function readPersistedModelSelection(
return undefined;
}
const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined;
return isModelSelection(raw) ? raw : undefined;
return Option.getOrUndefined(decodeModelSelectionOption(raw));
}

function readPersistedCwd(
Expand Down
126 changes: 122 additions & 4 deletions apps/server/src/serverRuntimeStartup.reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@ const makeThread = (
id: ThreadId.make(id),
archivedAt,
deletedAt,
modelSelection: {
instanceId: providerInstanceId,
model: "gpt-5.3-codex",
options: [{ id: "reasoningEffort", value: "high" }],
},
interactionMode: "default" as const,
session: {
threadId: ThreadId.make(id),
status,
providerName: "codex" as const,
providerName: ProviderDriverKind.make("codex"),
providerInstanceId,
runtimeMode: "full-access" as const,
activeTurnId,
Expand Down Expand Up @@ -193,6 +198,13 @@ it.effect.each(
recovery === "marked update" ? null : fallbackContinuationTurnId,
);
const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent");
fallback.modelSelection = {
instanceId: fallbackProviderInstanceId,
model: "claude-opus-4-6",
options: [{ id: "effort", value: "high" }],
};
fallback.session.providerName = ProviderDriverKind.make("claudeAgent");
fallback.session.providerInstanceId = fallbackProviderInstanceId;
const continuationSent = yield* Deferred.make<void>();
const continuationCleared = yield* Deferred.make<void>();
const sends: ProviderSendTurnInput[] = [];
Expand All @@ -212,6 +224,7 @@ it.effect.each(
status: "running" as const,
resumeCursor: { threadId: thread.id },
runtimePayload: {
modelSelection: thread.modelSelection,
activeTurnId:
thread.id === codex.id && persistedTurn !== "current"
? persistedTurn === "previous"
Expand Down Expand Up @@ -304,10 +317,16 @@ it.effect.each(
String(left.threadId).localeCompare(String(right.threadId)),
),
[
{ threadId: codex.id, continuation: true, interactionMode: "default" },
{
threadId: codex.id,
continuation: true,
modelSelection: codex.modelSelection,
interactionMode: "default",
},
{
threadId: fallback.id,
input: "Continue where you left off.",
modelSelection: fallback.modelSelection,
interactionMode: "default",
},
],
Expand Down Expand Up @@ -344,6 +363,7 @@ it.effect.each(
.filter((binding) => binding.threadId === thread.id)
.map((binding) => binding.runtimePayload)[0],
{
modelSelection: thread.modelSelection,
continueAfterServerUpdate: continuationTurnId,
continueAfterServerUpdatePrepared: true,
activeTurnId: null,
Expand All @@ -366,6 +386,93 @@ it.effect.each(
}),
);

it.effect("continues with the interrupted turn selection after thread metadata changes", () =>
Effect.gen(function* () {
const turnId = TurnId.make("turn-selection-changed");
const thread = makeThread("thread-selection-changed", "running", turnId);
const interruptedTurnSelection = thread.modelSelection;
const legacyInterruptedTurnSelection = {
provider: "codex",
model: interruptedTurnSelection.model,
options: interruptedTurnSelection.options,
};
thread.modelSelection = {
instanceId: ProviderInstanceId.make("opencode"),
model: "openai/gpt-5",
options: [],
};
const sent = yield* Deferred.make<ProviderSendTurnInput>();
const cleared = yield* Deferred.make<void>();
let binding: ProviderSessionDirectory.ProviderRuntimeBinding = {
threadId: thread.id,
provider: ProviderDriverKind.make("codex"),
providerInstanceId,
status: "running",
resumeCursor: { threadId: thread.id },
runtimePayload: {
activeTurnId: turnId,
modelSelection: legacyInterruptedTurnSelection,
},
};

yield* runReconciliation({
threads: [thread],
continueAfterRestart: true,
providerService: {
...makeProviderService(),
getCapabilities: () =>
Effect.succeed({
sessionModelSwitch: "in-session",
promptlessTurnContinuation: true,
}),
sendTurn: (input) =>
Deferred.succeed(sent, input).pipe(
Effect.as({
threadId: input.threadId,
turnId: TurnId.make("continued-selection-changed"),
}),
),
},
directory: {
getBinding: () => Effect.sync(() => Option.some(binding)),
upsert: (next) =>
Effect.gen(function* () {
binding = next;
const payload = next.runtimePayload;
if (
payload !== null &&
typeof payload === "object" &&
!Array.isArray(payload) &&
"continueAfterServerUpdate" in payload &&
payload.continueAfterServerUpdate === null
) {
yield* Deferred.succeed(cleared, undefined);
}
}),
recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
},
dispatch: () => Effect.succeed({ sequence: 1 }),
});

assert.deepStrictEqual(yield* Deferred.await(sent), {
threadId: thread.id,
continuation: true,
modelSelection: interruptedTurnSelection,
interactionMode: "default",
});
yield* Deferred.await(cleared);
assert.deepStrictEqual(binding.runtimePayload, {
activeTurnId: null,
modelSelection: legacyInterruptedTurnSelection,
continueAfterServerUpdate: null,
continueAfterServerUpdatePrepared: null,
});
}),
);

it.effect("does not continue archived or deleted marked sessions", () => {
const archived = makeThread(
"thread-continue-archived",
Expand Down Expand Up @@ -823,7 +930,10 @@ for (const preparedStatus of [
providerInstanceId,
status: "running",
resumeCursor: { threadId: thread.id },
runtimePayload: { activeTurnId: turnId },
runtimePayload: {
activeTurnId: turnId,
modelSelection: thread.modelSelection,
},
};
const input = {
threads: [thread],
Expand Down Expand Up @@ -881,6 +991,7 @@ for (const preparedStatus of [
assert.equal(thread.session.activeTurnId, null);
assert.deepStrictEqual(binding.runtimePayload, {
activeTurnId: null,
modelSelection: thread.modelSelection,
continueAfterServerUpdate: turnId,
continueAfterServerUpdatePrepared: true,
});
Expand All @@ -892,6 +1003,7 @@ for (const preparedStatus of [
status: "stopped",
runtimePayload: {
activeTurnId: null,
modelSelection: thread.modelSelection,
continueAfterServerUpdate: turnId,
continueAfterServerUpdatePrepared: null,
},
Expand All @@ -906,10 +1018,16 @@ for (const preparedStatus of [
yield* runReconciliation(input);
yield* Deferred.await(cleared);
assert.deepStrictEqual(sends, [
{ threadId: thread.id, continuation: true, interactionMode: "default" },
{
threadId: thread.id,
continuation: true,
modelSelection: thread.modelSelection,
interactionMode: "default",
},
]);
assert.deepStrictEqual(binding.runtimePayload, {
activeTurnId: null,
modelSelection: thread.modelSelection,
continueAfterServerUpdate: null,
continueAfterServerUpdatePrepared: null,
});
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_SERVER_SETTINGS,
type ServerSettings as ServerSettingsValue,
type ModelSelection,
ModelSelection,
type OrchestrationProjectShell,
ProjectId,
ProviderInstanceId,
Expand Down Expand Up @@ -388,6 +388,13 @@ function readRuntimePayload(runtimePayload: unknown): Record<string, unknown> {
: {};
}

const decodeModelSelectionOption = Schema.decodeUnknownOption(ModelSelection);

function readRuntimeModelSelection(runtimePayload: unknown): ModelSelection | undefined {
const value = readRuntimePayload(runtimePayload).modelSelection;
return Option.getOrUndefined(decodeModelSelectionOption(value));
}

const isServerUpdateThreadContinuationError = Schema.is(ServerUpdateThreadContinuationError);

function readServerUpdateContinuationTurnId(runtimePayload: unknown): TurnId | null {
Expand Down Expand Up @@ -700,12 +707,14 @@ export const reconcileProviderSessions = Effect.gen(function* () {
threadId: thread.id,
});
}
const modelSelection = readRuntimeModelSelection(binding.value.runtimePayload);
const capabilities = yield* providerService.getCapabilities(providerInstanceId);
yield* providerService.sendTurn({
threadId: thread.id,
...(capabilities.promptlessTurnContinuation === true
? { continuation: true }
: { input: SERVER_UPDATE_CONTINUATION_PROMPT }),
...(modelSelection !== undefined ? { modelSelection } : {}),
interactionMode: thread.interactionMode,
});
});
Expand Down
Loading