From 08732def05fd503c56404b5395e9e41dac13bc73 Mon Sep 17 00:00:00 2001 From: Kirill Alekhnovich Date: Thu, 24 Sep 2026 14:31:54 +0200 Subject: [PATCH] feat: Support custom instructions for agent sessions via meta --- src/AirExtension.ts | 10 ++++ src/CodexAcpClient.ts | 3 + src/CodexAcpServer.ts | 2 + .../CodexACPAgent/CodexAcpClient.test.ts | 58 +++++++++++++++++++ .../CodexACPAgent/initialize.test.ts | 2 +- 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/AirExtension.ts b/src/AirExtension.ts index 28af8784c..f62a53def 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -15,6 +15,7 @@ export const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities"; export const AIR_DIFF_STATS_KEY = "diffStats"; export const AIR_SESSION_FAILURE_KEY = "sessionFailure"; export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport"; +export const AIR_CUSTOM_INSTRUCTIONS_KEY = "customInstructions"; export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions"; export const AIR_ASYNC_TASKS_KEY = "asyncTasks"; export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue"; @@ -60,6 +61,15 @@ export function clientSupportsAirCapability( && supported.includes(capability); } +/** Read custom instructions from `session/new` metadata. */ +export function airCustomInstructions(meta: unknown): string | undefined { + const root = asRecord(meta); + const jetbrains = asRecord(root[JETBRAINS_META_KEY]); + const air = asRecord(jetbrains[AIR_META_KEY]); + const instructions = air[AIR_CUSTOM_INSTRUCTIONS_KEY]; + return typeof instructions === "string" ? instructions : undefined; +} + function asRecord(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 19c81e9f9..e02240775 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -60,6 +60,7 @@ import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike} from "./PathUt import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; +import {airCustomInstructions} from "./AirExtension"; import {isMissingRolloutError, isUnknownThreadError} from "./CodexThreadErrors"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; @@ -666,12 +667,14 @@ export class CodexAcpClient { async newSession(request: acp.NewSessionRequest): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); + const developerInstructions = airCustomInstructions(request._meta); await this.refreshSkills(request.cwd, additionalDirectories); const response = await this.codexClient.threadStart({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers), modelProvider: this.getModelProvider(), cwd: request.cwd, + ...(developerInstructions !== undefined && {developerInstructions}), }); const codexModels = await this.fetchAvailableModels(); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index e640ae1d2..bc3b32766 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -137,6 +137,7 @@ import {TitleGenerator} from "./TitleGenerator"; import {once} from "node:events"; import { AIR_AGENT_FILE_CHANGE_REPORT_KEY, + AIR_CUSTOM_INSTRUCTIONS_KEY, AIR_ASYNC_TASKS_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_RECOMMENDED_CONFIG_VALUE_KEY, @@ -422,6 +423,7 @@ export class CodexAcpServer { [AIR_EXTENSION_CAPABILITIES_KEY]: [ AIR_SESSION_FAILURE_KEY, AIR_AGENT_FILE_CHANGE_REPORT_KEY, + AIR_CUSTOM_INSTRUCTIONS_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_ASYNC_TASKS_KEY, AIR_RECOMMENDED_CONFIG_VALUE_KEY, diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d1e27aee4..54d2a0569 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -478,6 +478,64 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(listSkillsSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); }); + it('passes AIR custom instructions to thread start', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + reasoningEffort: "medium", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + await codexAcpClient.newSession({ + cwd: "/workspace", + mcpServers: [], + _meta: { + jetbrains: { + air: { + customInstructions: "Follow the project rules.", + }, + }, + }, + }); + + expect(threadStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + developerInstructions: "Follow the project rules.", + })); + }); + + it('ignores malformed AIR custom instructions', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + reasoningEffort: "medium", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + await codexAcpClient.newSession({ + cwd: "/workspace", + mcpServers: [], + _meta: {jetbrains: {air: {customInstructions: 42}}}, + }); + + expect(threadStartSpy.mock.calls[0]![0].developerInstructions).toBeUndefined(); + }); + it('prefers ACP additional directories over legacy meta roots for new session skill discovery', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e6bdb8bb3..41d356e6d 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -78,7 +78,7 @@ describe('CodexACPAgent - initialize', () => { jetbrains: { air: { version: 1, - capabilities: ["sessionFailure", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue"], + capabilities: ["sessionFailure", "agentFileChangeReport", "customInstructions", "nativeSubagentSessions", "asyncTasks", "recommendedValue"], }, }, },