Skip to content
Merged
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: 2 additions & 1 deletion apps/server/scripts/migrate-dev-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
);
assert.includeMembers(
migrated.projectColumns.map(({ name }) => name),
["default_thread_env_mode", "favicon_path"],
["default_thread_env_mode", "favicon_path", "additional_instructions"],
);
assert.deepStrictEqual(migrated.upstreamHistory, [
{
Expand All @@ -223,6 +223,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
assert.deepStrictEqual(migrated.forkHistory, [
{ migration_id: 1, name: "ComposerDrafts" },
{ migration_id: 2, name: "WorkspacePortAllocations" },
{ migration_id: 3, name: "ProjectAdditionalInstructions" },
]);
assert.deepStrictEqual(migrated.drafts, [{ thread_id: "stopped-thread" }]);
}),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
defaultModelSelection: event.payload.defaultModelSelection,
defaultThreadEnvMode: null,
faviconPath: event.payload.faviconPath ?? null,
additionalInstructions: event.payload.additionalInstructions ?? null,
scripts: event.payload.scripts,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand Down Expand Up @@ -526,6 +527,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
...(event.payload.faviconPath !== undefined
? { faviconPath: event.payload.faviconPath }
: {}),
...(event.payload.additionalInstructions !== undefined
? { additionalInstructions: event.payload.additionalInstructions }
: {}),
...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
updatedAt: event.payload.updatedAt,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
model: "gpt-5-codex",
},
faviconPath: null,
additionalInstructions: null,
scripts: [
{
id: "script-1",
Expand Down Expand Up @@ -396,6 +397,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
model: "gpt-5-codex",
},
faviconPath: null,
additionalInstructions: null,
scripts: [
{
id: "script-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ function mapProjectShellRow(
defaultModelSelection: row.defaultModelSelection,
defaultThreadEnvMode: row.defaultThreadEnvMode,
faviconPath: row.faviconPath ?? null,
additionalInstructions: row.additionalInstructions,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -399,6 +400,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
default_model_selection_json AS "defaultModelSelection",
default_thread_env_mode AS "defaultThreadEnvMode",
favicon_path AS "faviconPath",
additional_instructions AS "additionalInstructions",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -854,6 +856,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
default_model_selection_json AS "defaultModelSelection",
default_thread_env_mode AS "defaultThreadEnvMode",
favicon_path AS "faviconPath",
additional_instructions AS "additionalInstructions",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand All @@ -878,6 +881,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
default_model_selection_json AS "defaultModelSelection",
default_thread_env_mode AS "defaultThreadEnvMode",
favicon_path AS "faviconPath",
additional_instructions AS "additionalInstructions",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -1679,6 +1683,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
defaultModelSelection: row.defaultModelSelection,
defaultThreadEnvMode: row.defaultThreadEnvMode,
faviconPath: row.faviconPath ?? null,
additionalInstructions: row.additionalInstructions,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -1810,6 +1815,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
defaultModelSelection: row.defaultModelSelection,
defaultThreadEnvMode: row.defaultThreadEnvMode,
faviconPath: row.faviconPath ?? null,
additionalInstructions: row.additionalInstructions,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -2303,6 +2309,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
defaultModelSelection: option.value.defaultModelSelection,
defaultThreadEnvMode: option.value.defaultThreadEnvMode,
faviconPath: option.value.faviconPath ?? null,
additionalInstructions: option.value.additionalInstructions,
scripts: option.value.scripts,
createdAt: option.value.createdAt,
updatedAt: option.value.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,47 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.runtimeMode).toBe("approval-required");
});

it("passes project additional instructions when starting a provider session", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "project.meta.update",
commandId: CommandId.make("cmd-project-instructions"),
projectId: asProjectId("project-1"),
additionalInstructions: "Prefer focused tests.",
}),
);
await waitFor(async () =>
(await harness.readModel()).projects.some(
(project) => project.additionalInstructions === "Prefer focused tests.",
),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-instructions"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-instructions"),
role: "user",
text: "hello reactor",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
additionalInstructions: "Prefer focused tests.",
});
});

it("adopts a Codex follow-up when the response id differs from the retained active turn", async () => {
const harness = await createHarness();
const threadId = ThreadId.make("thread-1");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,9 @@ const make = Effect.gen(function* () {
...(effectiveCwd ? { cwd: effectiveCwd } : {}),
...(thread.title ? { title: thread.title } : {}),
modelSelection: desiredModelSelection,
...(project?.additionalInstructions
? { additionalInstructions: project.additionalInstructions }
: {}),
...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
runtimeMode: desiredRuntimeMode,
});
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,36 @@ it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => {
expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull();
}),
);

it.effect("sets and clears project additional instructions", () =>
Effect.gen(function* () {
const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));

const set = yield* decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.make("cmd-project-instructions-set"),
projectId,
additionalInstructions: "Always run focused tests.",
},
readModel,
});
const setEvent = Array.isArray(set) ? set[0] : set;
const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 });
expect(afterSet.projects[0]?.additionalInstructions).toBe("Always run focused tests.");

const clear = yield* decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.make("cmd-project-instructions-clear"),
projectId,
additionalInstructions: null,
},
readModel: afterSet,
});
const clearEvent = Array.isArray(clear) ? clear[0] : clear;
const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 });
expect(afterClear.projects[0]?.additionalInstructions).toBeNull();
}),
);
});
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
workspaceRoot: command.workspaceRoot,
defaultModelSelection: command.defaultModelSelection ?? null,
faviconPath: null,
additionalInstructions: null,
scripts: [],
createdAt: command.createdAt,
updatedAt: command.createdAt,
Expand Down Expand Up @@ -292,6 +293,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
? { defaultThreadEnvMode: command.defaultThreadEnvMode }
: {}),
...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
...(command.additionalInstructions !== undefined
? { additionalInstructions: command.additionalInstructions }
: {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
updatedAt: occurredAt,
},
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export function projectEvent(
defaultModelSelection: payload.defaultModelSelection,
defaultThreadEnvMode: null,
faviconPath: payload.faviconPath ?? null,
additionalInstructions: payload.additionalInstructions ?? null,
scripts: payload.scripts,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
Expand Down Expand Up @@ -254,6 +255,9 @@ export function projectEvent(
...(payload.faviconPath !== undefined
? { faviconPath: payload.faviconPath }
: {}),
...(payload.additionalInstructions !== undefined
? { additionalInstructions: payload.additionalInstructions }
: {}),
...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
updatedAt: payload.updatedAt,
}
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/persistence/ForkMigrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ legacyForkLayer("ForkMigrations legacy fork upgrade", (it) => {

assert.isTrue(result.repairedLegacyHistory);
assert.deepStrictEqual(result.upstream, [[40, "ProjectionProjectFaviconPath"]]);
assert.deepStrictEqual(result.fork, [[2, "WorkspacePortAllocations"]]);
assert.deepStrictEqual(result.fork, [
[2, "WorkspacePortAllocations"],
[3, "ProjectAdditionalInstructions"],
]);

const upstreamHistory = yield* sql<{
readonly migration_id: number;
Expand Down Expand Up @@ -81,14 +84,15 @@ legacyForkLayer("ForkMigrations legacy fork upgrade", (it) => {
assert.deepStrictEqual(forkHistory, [
{ migration_id: 1, name: "ComposerDrafts" },
{ migration_id: 2, name: "WorkspacePortAllocations" },
{ migration_id: 3, name: "ProjectAdditionalInstructions" },
]);

const projectColumns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_projects)
`;
assert.includeMembers(
projectColumns.map(({ name }) => name),
["default_thread_env_mode", "favicon_path"],
["default_thread_env_mode", "favicon_path", "additional_instructions"],
);

const draftRows = yield* sql<{
Expand Down Expand Up @@ -199,6 +203,7 @@ upstreamLayer("ForkMigrations canonical upstream upgrade", (it) => {
assert.deepStrictEqual(forkHistory, [
{ migration_id: 1, name: "ComposerDrafts" },
{ migration_id: 2, name: "WorkspacePortAllocations" },
{ migration_id: 3, name: "ProjectAdditionalInstructions" },
]);
}),
);
Expand All @@ -223,5 +228,6 @@ it("keeps fork migrations out of the upstream manifest", () => {
assert.deepStrictEqual(forkMigrationManifest, [
[1, "ComposerDrafts"],
[2, "WorkspacePortAllocations"],
[3, "ProjectAdditionalInstructions"],
]);
});
2 changes: 2 additions & 0 deletions apps/server/src/persistence/ForkMigrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";

import ForkMigration0001 from "./ForkMigrations/001_ComposerDrafts.ts";
import ForkMigration0002 from "./ForkMigrations/002_WorkspacePortAllocations.ts";
import ForkMigration0003 from "./ForkMigrations/003_ProjectAdditionalInstructions.ts";
import UpstreamMigration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
import { runMigrations } from "./Migrations.ts";

Expand All @@ -19,6 +20,7 @@ const UPSTREAM_MIGRATION_0039_NAME = "ProjectionProjectsDefaultThreadEnvMode";
export const forkMigrationEntries = [
[1, "ComposerDrafts", ForkMigration0001],
[2, "WorkspacePortAllocations", ForkMigration0002],
[3, "ProjectAdditionalInstructions", ForkMigration0003],
] as const;

export const forkMigrationManifest = forkMigrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 { runForkMigrations } from "../ForkMigrations.ts";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("003_ProjectAdditionalInstructions", (it) => {
it.effect("adds nullable additional instructions to project projections", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations();
yield* runForkMigrations({ toMigrationInclusive: 2 });
yield* runForkMigrations({ toMigrationInclusive: 3 });

const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
PRAGMA table_info(projection_projects)
`;
const additionalInstructions = columns.find(
(column) => column.name === "additional_instructions",
);

assert.equal(additionalInstructions?.name, "additional_instructions");
assert.equal(additionalInstructions?.notnull, 0);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_projects)
`;

if (!columns.some((column) => column.name === "additional_instructions")) {
yield* sql`
ALTER TABLE projection_projects
ADD COLUMN additional_instructions TEXT
`;
}
});
5 changes: 5 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
default_model_selection_json,
default_thread_env_mode,
favicon_path,
additional_instructions,
scripts_json,
created_at,
updated_at,
Expand All @@ -49,6 +50,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
${row.defaultThreadEnvMode},
${row.faviconPath ?? null},
${row.additionalInstructions},
${JSON.stringify(row.scripts)},
${row.createdAt},
${row.updatedAt},
Expand All @@ -61,6 +63,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
default_model_selection_json = excluded.default_model_selection_json,
default_thread_env_mode = excluded.default_thread_env_mode,
favicon_path = excluded.favicon_path,
additional_instructions = excluded.additional_instructions,
scripts_json = excluded.scripts_json,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
Expand All @@ -80,6 +83,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
default_model_selection_json AS "defaultModelSelection",
default_thread_env_mode AS "defaultThreadEnvMode",
favicon_path AS "faviconPath",
additional_instructions AS "additionalInstructions",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand All @@ -101,6 +105,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
default_model_selection_json AS "defaultModelSelection",
default_thread_env_mode AS "defaultThreadEnvMode",
favicon_path AS "faviconPath",
additional_instructions AS "additionalInstructions",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
model: "gpt-5.4",
},
defaultThreadEnvMode: null,
additionalInstructions: null,
scripts: [],
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
Expand Down
Loading
Loading