diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 7170cb06c06..45c2461c687 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -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, [ { @@ -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" }]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 978ed0cf7b2..21c7175b34c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -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, @@ -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, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049..56bfdf832b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -276,6 +276,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, faviconPath: null, + additionalInstructions: null, scripts: [ { id: "script-1", @@ -396,6 +397,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, faviconPath: null, + additionalInstructions: null, scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8..60e5fd6c7bb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -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, @@ -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", @@ -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", @@ -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", @@ -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, @@ -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, @@ -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, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 1e433e94c35..357f2b5e557 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -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"); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 14e7a979c06..40dfbb0b620 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -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, }); diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts index afee13343ad..62184edcb21 100644 --- a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -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(); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6a..5f7a08f18fd 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -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, @@ -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, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcb..7f282d2990e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -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, @@ -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, } diff --git a/apps/server/src/persistence/ForkMigrations.test.ts b/apps/server/src/persistence/ForkMigrations.test.ts index 31f49b84e0f..32a8835a8fa 100644 --- a/apps/server/src/persistence/ForkMigrations.test.ts +++ b/apps/server/src/persistence/ForkMigrations.test.ts @@ -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; @@ -81,6 +84,7 @@ 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 }>` @@ -88,7 +92,7 @@ legacyForkLayer("ForkMigrations legacy fork upgrade", (it) => { `; assert.includeMembers( projectColumns.map(({ name }) => name), - ["default_thread_env_mode", "favicon_path"], + ["default_thread_env_mode", "favicon_path", "additional_instructions"], ); const draftRows = yield* sql<{ @@ -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" }, ]); }), ); @@ -223,5 +228,6 @@ it("keeps fork migrations out of the upstream manifest", () => { assert.deepStrictEqual(forkMigrationManifest, [ [1, "ComposerDrafts"], [2, "WorkspacePortAllocations"], + [3, "ProjectAdditionalInstructions"], ]); }); diff --git a/apps/server/src/persistence/ForkMigrations.ts b/apps/server/src/persistence/ForkMigrations.ts index 777afc8872b..afb7745b05e 100644 --- a/apps/server/src/persistence/ForkMigrations.ts +++ b/apps/server/src/persistence/ForkMigrations.ts @@ -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"; @@ -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); diff --git a/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.test.ts b/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.test.ts new file mode 100644 index 00000000000..41818c6d9ec --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.test.ts @@ -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); + }), + ); +}); diff --git a/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.ts b/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.ts new file mode 100644 index 00000000000..f76ed7b22a8 --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations/003_ProjectAdditionalInstructions.ts @@ -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 + `; + } +}); diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index ba133bb24a4..61b625c3b32 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -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, @@ -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}, @@ -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, @@ -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", @@ -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", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7..078fc9fb2a6 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -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", diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 339439fdfcb..7e7084e2a86 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + ProjectAdditionalInstructions, ProjectId, ProjectScript, ThreadEnvMode, @@ -27,6 +28,7 @@ export const ProjectionProject = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), faviconPath: Schema.optional(Schema.NullOr(Schema.String)), + additionalInstructions: Schema.NullOr(ProjectAdditionalInstructions), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index aa7d106e102..badf21af3e6 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -169,6 +169,7 @@ function toSingleLine(value: string): string { export function buildCodexDeveloperInstructions( interactionMode: ProviderInteractionMode, runtime: CodexRuntimeInfo, + additionalInstructions?: string, ): string { const base = interactionMode === "plan" @@ -176,5 +177,5 @@ export function buildCodexDeveloperInstructions( : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; return `${base} -In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`; +In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.${additionalInstructions ? `\n\n\n${additionalInstructions}\n` : ""}`; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 60affcdb183..168caf5b2d5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -273,6 +273,28 @@ const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => { + it.effect("appends project instructions to Claude's preset system prompt", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + additionalInstructions: "Prefer focused tests.", + runtimeMode: "full-access", + }); + + assert.deepEqual(harness.getLastCreateQueryInput()?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: "Prefer focused tests.", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e0b7b402265..977d6671efb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4162,7 +4162,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, - systemPrompt: { type: "preset", preset: "claude_code" }, + systemPrompt: { + type: "preset", + preset: "claude_code", + ...(input.additionalInstructions ? { append: input.additionalInstructions } : {}), + }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is // normalized to `xhigh` above and paired with `settings.ultracode`. diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b53ecf97dd9..c3bd046def6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1693,6 +1693,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: input.runtimeMode, + ...(input.additionalInstructions + ? { additionalInstructions: input.additionalInstructions } + : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 2a2de683920..16f03c52f0d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -249,6 +249,22 @@ describe("buildTurnStartParams", () => { }); describe("buildCodexDeveloperInstructions", () => { + it("appends project instructions in a distinct developer block", () => { + const instructions = buildCodexDeveloperInstructions( + "default", + { + model: "gpt-5.4", + reasoningEffort: "high", + }, + "Prefer focused tests.", + ); + + NodeAssert.match( + instructions, + /\nPrefer focused tests\.\n<\/additional_instructions>$/, + ); + }); + it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { model: "gpt-5.3-codex", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 8f887c1b592..21b7227209f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -107,6 +107,7 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + readonly additionalInstructions?: string; } export interface CodexSessionRuntimeSendTurnInput { @@ -340,6 +341,7 @@ function buildCodexCollaborationMode(input: { readonly interactionMode?: ProviderInteractionMode; readonly model?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; + readonly additionalInstructions?: string; }): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { if (input.interactionMode === undefined) { return undefined; @@ -351,10 +353,14 @@ function buildCodexCollaborationMode(input: { settings: { model, reasoning_effort: reasoningEffort, - developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, { - model, - reasoningEffort, - }), + developer_instructions: buildCodexDeveloperInstructions( + input.interactionMode, + { + model, + reasoningEffort, + }, + input.additionalInstructions, + ), }, }; } @@ -371,6 +377,7 @@ export function buildTurnStartParams(input: { readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; + readonly additionalInstructions?: string; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -391,6 +398,9 @@ export function buildTurnStartParams(input: { ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), ...(input.model ? { model: input.model } : {}), ...(input.effort ? { effort: input.effort } : {}), + ...(input.additionalInstructions + ? { additionalInstructions: input.additionalInstructions } + : {}), }); return decodeCodexTurnStartParamsWithCollaborationMode({ @@ -1822,6 +1832,9 @@ export const makeCodexSessionRuntime = ( ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + ...(options.additionalInstructions + ? { additionalInstructions: options.additionalInstructions } + : {}), }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index cd5cdb7f01a..02368a3808b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -168,6 +168,53 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("prefixes project instructions only on the first fresh Cursor prompt", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-additional-instructions"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-instructions-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + additionalInstructions: "Prefer focused tests.", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "First request" }); + yield* adapter.sendTurn({ threadId, input: "Second request" }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const prompts = requests.filter((entry) => entry.method === "session/prompt"); + const promptBlocks = (entry: Record) => + (entry.params as { prompt?: Array<{ type?: string; text?: string }> } | undefined) + ?.prompt ?? []; + + assert.deepStrictEqual( + promptBlocks(prompts[0] ?? {}).map((block) => block.text), + [ + "\nPrefer focused tests.\n", + "First request", + ], + ); + assert.deepStrictEqual( + promptBlocks(prompts[1] ?? {}).map((block) => block.text), + ["Second request"], + ); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 89fe084f7c0..b03b2866e3a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -131,6 +131,7 @@ interface CursorSessionContext { readonly pendingApprovals: Map; readonly pendingUserInputs: Map; readonly turns: Array<{ id: TurnId; items: Array }>; + additionalInstructionsPending: string | undefined; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; /** Number of sendTurn prompts currently in flight or being prepared. @@ -779,6 +780,8 @@ export function makeCursorAdapter( pendingApprovals, pendingUserInputs, turns: [], + additionalInstructionsPending: + resumeSessionId === undefined ? input.additionalInstructions : undefined, lastPlanFingerprint: undefined, activeTurnId: undefined, promptsInFlight: 0, @@ -970,6 +973,15 @@ export function makeCursorAdapter( } const promptParts: Array = []; + const additionalInstructions = + steeringTurnId === undefined ? ctx.additionalInstructionsPending : undefined; + if (additionalInstructions) { + ctx.additionalInstructionsPending = undefined; + promptParts.push({ + type: "text", + text: `\n${additionalInstructions}\n`, + }); + } if (input.input?.trim()) { promptParts.push({ type: "text", text: input.input.trim() }); } diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 25ab0a16a98..3822209f865 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -578,6 +578,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), childProcessSpawner, cwd, + ...(input.additionalInstructions + ? { additionalInstructions: input.additionalInstructions } + : {}), ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1..581cdc5c1ac 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -281,6 +281,31 @@ const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("passes project instructions through the OpenCode system field", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-instructions"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + additionalInstructions: "Prefer focused tests.", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "Fix it" }); + + NodeAssert.equal( + (runtimeMock.state.promptCalls.at(-1) as { system?: string }).system, + "Prefer focused tests.", + ); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 237e0f58415..5b926b8d6a6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -223,6 +223,7 @@ function isOpenCodeDefaultTitle(title: string): boolean { interface OpenCodeSessionContext { session: ProviderSession; + readonly additionalInstructions: string | undefined; readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; @@ -1391,6 +1392,7 @@ export function makeOpenCodeAdapter( const context: OpenCodeSessionContext = { session, + additionalInstructions: input.additionalInstructions, client: started.client, server: started.server, directory, @@ -1508,6 +1510,7 @@ export function makeOpenCodeAdapter( model: parsedModel, ...(context.activeAgent ? { agent: context.activeAgent } : {}), ...(context.activeVariant ? { variant: context.activeVariant } : {}), + ...(context.additionalInstructions ? { system: context.additionalInstructions } : {}), parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }), ).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1d1134f1b75..1480f9132e1 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1184,7 +1184,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("recovers stale sessions for sendTurn using persisted cwd", () => + it.effect("recovers stale sessions using persisted cwd and project instructions", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1193,6 +1193,7 @@ routing.layer("ProviderServiceLive routing", (it) => { providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project-send-turn", + additionalInstructions: "Prefer focused tests.", runtimeMode: "full-access", }); @@ -1213,11 +1214,13 @@ routing.layer("ProviderServiceLive routing", (it) => { const startPayload = resumedStartInput as { provider?: string; cwd?: string; + additionalInstructions?: string; resumeCursor?: unknown; threadId?: string; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project-send-turn"); + assert.equal(startPayload.additionalInstructions, "Prefer focused tests."); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 5e515f24649..827a6598b10 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -129,6 +129,7 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly additionalInstructions?: string; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -139,6 +140,9 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.additionalInstructions !== undefined + ? { additionalInstructions: extra.additionalInstructions } + : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -168,6 +172,19 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedAdditionalInstructions( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = + "additionalInstructions" in runtimePayload ? runtimePayload.additionalInstructions : undefined; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -278,6 +295,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly additionalInstructions?: string; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -384,6 +402,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const adapter = yield* registry.getByInstance(bindingInstanceId); const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; + const persistedAdditionalInstructions = readPersistedAdditionalInstructions( + input.binding.runtimePayload, + ); const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); if (hasActiveSession) { const activeSessions = yield* adapter.listSessions(); @@ -394,6 +415,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding( { ...existing, providerInstanceId: bindingInstanceId }, input.binding.threadId, + persistedAdditionalInstructions + ? { additionalInstructions: persistedAdditionalInstructions } + : undefined, ); yield* analytics.record("provider.session.recovered", { provider: existing.provider, @@ -428,6 +452,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(workspaceEnvironment ? { environment: workspaceEnvironment } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(persistedAdditionalInstructions + ? { additionalInstructions: persistedAdditionalInstructions } + : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }) @@ -443,6 +470,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding( { ...resumed, providerInstanceId: bindingInstanceId }, input.binding.threadId, + persistedAdditionalInstructions + ? { additionalInstructions: persistedAdditionalInstructions } + : undefined, ); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, @@ -652,6 +682,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* upsertSessionBinding(sessionWithInstance, threadId, { modelSelection: input.modelSelection, + ...(input.additionalInstructions + ? { additionalInstructions: input.additionalInstructions } + : {}), }); yield* analytics.record("provider.session.started", { provider: sessionWithInstance.provider, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24..7c5d9b8b83f 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -33,6 +33,17 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("passes additional instructions through Grok's rules option", () => { + const spawn = buildGrokAcpSpawnInput( + undefined, + "/tmp/project", + undefined, + "Prefer focused tests.", + ); + + expect(spawn.args).toEqual(["--rules", "Prefer focused tests.", "agent", "stdio"]); + }); }); describe("applyGrokAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e..6e5e4e7ddf6 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -27,16 +27,22 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly additionalInstructions?: string; } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + additionalInstructions?: string, ): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: [ + ...(additionalInstructions ? ["--rules", additionalInstructions] : []), + "agent", + "stdio", + ], cwd, env: { ...environment, @@ -62,7 +68,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.additionalInstructions, + ), authMethodId: resolveGrokAuthMethodId(input.environment), }).pipe( Layer.provide( diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 516e2e8c5cd..eeb22d52f9a 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -88,6 +88,7 @@ import { cn } from "../../lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; import { Menu, MenuGroup, @@ -368,6 +369,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; const faviconPath = representative.faviconPath ?? null; + const additionalInstructions = representative.additionalInstructions ?? null; const threadCountByMember = useMemo(() => { const counts = new Map(); @@ -398,6 +400,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; faviconPath: string | null; + additionalInstructions: string | null; }>, failureTitle: string, ): Promise> => { @@ -474,6 +477,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); + const setAdditionalInstructions = useCallback( + (instructions: string | null) => + void updateAllMembers( + { additionalInstructions: instructions }, + "Failed to update additional instructions", + ), + [updateAllMembers], + ); + // ----- favicon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); @@ -993,6 +1005,35 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> + setAdditionalInstructions(null)} + /> + ) : null + } + control={ +