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
3 changes: 1 addition & 2 deletions apps/server/src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn
import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
import {
clearPersistedServerRuntimeState,
readPersistedServerRuntimeState,
Expand Down Expand Up @@ -481,7 +480,7 @@ const projectAddCommand = Command.make("add", {
projectId,
title,
workspaceRoot,
defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
defaultModelSelection: null,
createdAt: DateTime.formatIso(yield* DateTime.now),
});
return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
import Migration0044 from "./Migrations/044_ClearImplicitProjectModelDefaults.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -111,6 +112,7 @@ export const migrationEntries = [
[41, "AuthSessionClientConnection", Migration0041],
[42, "ProjectionThreadLinkedPullRequest", Migration0042],
[43, "ProjectionThreadsUnsettledAt", Migration0043],
[44, "ClearImplicitProjectModelDefaults", Migration0044],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
const createdAt = "2026-01-01T00:00:00.000Z";

layer("044_ClearImplicitProjectModelDefaults", (it) => {
it.effect("clears only the known implicit creation default", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 43 });

const insertProject = (projectId: string, defaultModelSelection: string) => sql`
INSERT INTO projection_projects (
project_id, title, workspace_root, default_model_selection_json,
scripts_json, created_at, updated_at
) VALUES (
${projectId}, ${projectId}, ${`/tmp/${projectId}`}, ${defaultModelSelection},
'[]', ${createdAt}, ${createdAt}
)
`;
const insertEvent = (
eventId: string,
streamId: string,
streamVersion: number,
eventType: string,
payload: string,
) => sql`
INSERT INTO orchestration_events (
event_id, aggregate_kind, stream_id, stream_version, event_type,
occurred_at, actor_kind, payload_json, metadata_json
) VALUES (
${eventId}, 'project', ${streamId}, ${streamVersion}, ${eventType},
${createdAt}, 'user', ${payload}, '{}'
)
`;

const generated = '{"instanceId":"codex","model":"gpt-5.4"}';
const explicit = '{"instanceId":"claudeAgent","model":"claude-sonnet-5"}';
const callerSupplied = '{"instanceId":"codex","model":"gpt-5-codex"}';
yield* insertProject("implicit", generated);
yield* insertEvent(
"created-implicit",
"implicit",
1,
"project.created",
`{"defaultModelSelection":${generated}}`,
);
yield* insertEvent(
"renamed-implicit",
"implicit",
2,
"project.meta-updated",
'{"title":"Renamed"}',
);

yield* insertProject("explicit", explicit);
yield* insertEvent(
"created-explicit",
"explicit",
1,
"project.created",
`{"defaultModelSelection":${generated}}`,
);
yield* insertEvent(
"updated-explicit",
"explicit",
2,
"project.meta-updated",
`{"defaultModelSelection":${explicit}}`,
);

yield* insertProject("caller-supplied", callerSupplied);
yield* insertEvent(
"created-caller-supplied",
"caller-supplied",
1,
"project.created",
`{"defaultModelSelection":${callerSupplied}}`,
);

yield* runMigrations({ toMigrationInclusive: 44 });

const projects = yield* sql<{
readonly projectId: string;
readonly defaultModelSelection: string | null;
}>`
SELECT project_id AS "projectId",
default_model_selection_json AS "defaultModelSelection"
FROM projection_projects
ORDER BY project_id
`;
assert.deepStrictEqual(projects, [
{ projectId: "caller-supplied", defaultModelSelection: callerSupplied },
{ projectId: "explicit", defaultModelSelection: explicit },
{ projectId: "implicit", defaultModelSelection: null },
]);

const rewritten = yield* sql<{ readonly rewrittenCount: number }>`
SELECT COUNT(*) AS "rewrittenCount"
FROM orchestration_events
WHERE event_type = 'project.created'
AND json_type(payload_json, '$.defaultModelSelection') = 'null'
`;
assert.equal(rewritten[0]?.rewrittenCount, 2);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// The old client creation path always wrote this exact bare Codex default.
// Keep every other creation-time selection because the command contract
// also permits callers to supply a legitimate project default.
yield* sql`
UPDATE projection_projects
SET default_model_selection_json = NULL
WHERE default_model_selection_json IS NOT NULL
AND EXISTS (
SELECT 1
FROM orchestration_events AS created
WHERE created.aggregate_kind = 'project'
AND created.stream_id = projection_projects.project_id
AND created.event_type = 'project.created'
AND COALESCE(
json_extract(created.payload_json, '$.defaultModelSelection.instanceId'),
json_extract(created.payload_json, '$.defaultModelSelection.provider')
) = 'codex'
AND json_extract(created.payload_json, '$.defaultModelSelection.model') = 'gpt-5.4'
AND json_type(created.payload_json, '$.defaultModelSelection.options') IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM orchestration_events AS updated
WHERE updated.aggregate_kind = 'project'
AND updated.stream_id = projection_projects.project_id
AND updated.event_type = 'project.meta-updated'
AND json_type(updated.payload_json, '$.defaultModelSelection') IS NOT NULL
)
`;

// Keep replay consistent with the repaired projection.
yield* sql`
UPDATE orchestration_events
SET payload_json = json_set(payload_json, '$.defaultModelSelection', json('null'))
WHERE aggregate_kind = 'project'
AND event_type = 'project.created'
AND COALESCE(
json_extract(payload_json, '$.defaultModelSelection.instanceId'),
json_extract(payload_json, '$.defaultModelSelection.provider')
) = 'codex'
AND json_extract(payload_json, '$.defaultModelSelection.model') = 'gpt-5.4'
AND json_type(payload_json, '$.defaultModelSelection.options') IS NULL
`;
});
25 changes: 21 additions & 4 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import {
DEFAULT_MODEL,
type OrchestrationCommand,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -185,7 +191,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa

it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>
Effect.gen(function* () {
const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
const dispatchCalls = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
Effect.provideService(ServerConfig.ServerConfig, {
cwd: "/tmp/startup-project",
Expand All @@ -211,7 +217,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
readEvents: () => Stream.empty,
dispatch: (command) =>
Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe(
Effect.as({ sequence: 1 }),
),
streamDomainEvents: Stream.empty,
Expand All @@ -222,7 +228,18 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when

assert.equal(typeof targets.bootstrapProjectId, "string");
assert.equal(typeof targets.bootstrapThreadId, "string");
assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]);
const calls = yield* Ref.get(dispatchCalls);
assert.equal(calls[0]?.type, "project.create");
assert.equal(calls[1]?.type, "thread.create");
if (calls[0]?.type === "project.create") {
assert.equal(calls[0].defaultModelSelection, null);
}
if (calls[1]?.type === "thread.create") {
assert.deepStrictEqual(
calls[1].modelSelection,
ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
);
}
}),
);

Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,25 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
serverConfig.cwd,
);
let nextProjectId: ProjectId;
let nextProjectDefaultModelSelection: ModelSelection;
let nextThreadModelSelection: ModelSelection;

if (Option.isNone(existingProject)) {
const createdAt = DateTime.formatIso(yield* DateTime.now);
nextProjectId = ProjectId.make(yield* randomUUID);
const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection();
nextThreadModelSelection = getAutoBootstrapDefaultModelSelection();
yield* orchestrationEngine.dispatch({
type: "project.create",
commandId: CommandId.make(yield* randomUUID),
projectId: nextProjectId,
title: bootstrapProjectTitle,
workspaceRoot: serverConfig.cwd,
defaultModelSelection: nextProjectDefaultModelSelection,
defaultModelSelection: null,
createdAt,
});
} else {
nextProjectId = existingProject.value.id;
nextProjectDefaultModelSelection =
nextThreadModelSelection =
existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection();
}

Expand All @@ -232,7 +232,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
threadId: createdThreadId,
projectId: nextProjectId,
title: "New thread",
modelSelection: nextProjectDefaultModelSelection,
modelSelection: nextThreadModelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
Expand Down
16 changes: 2 additions & 14 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,7 @@ import {
} from "./ThreadCommandSubtitle";
import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
import {
deriveProviderInstanceEntries,
resolveDefaultProviderModelSelection,
type ProviderInstanceEntry,
} from "../providerInstances";
import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -1756,21 +1752,14 @@ function OpenCommandPaletteDialog(props: {
}

const projectId = newProjectId();
const targetEnvironmentProviders =
environments.find((environment) => environment.environmentId === input.environmentId)
?.serverConfig?.providers ??
(input.environmentId === primaryEnvironmentId ? providers : []);
const createResult = await createProject({
environmentId: input.environmentId,
input: {
projectId,
title: inferProjectTitleFromPath(cwd),
workspaceRoot: cwd,
createWorkspaceRootIfMissing: true,
defaultModelSelection: resolveDefaultProviderModelSelection(
targetEnvironmentProviders,
null,
),
defaultModelSelection: null,
},
});
if (createResult._tag === "Failure") {
Expand Down Expand Up @@ -1810,7 +1799,6 @@ function OpenCommandPaletteDialog(props: {
navigate,
primaryEnvironmentId,
projects,
providers,
setOpen,
clientSettings.sidebarThreadSortOrder,
threads,
Expand Down
Loading