From 97aa595ba69c62e896375ea2442358d45d66af74 Mon Sep 17 00:00:00 2001 From: Nacho Lopez Date: Fri, 2 Oct 2026 21:01:17 +0200 Subject: [PATCH] fix: allow non-AIR clients to opt into goals --- docs/air-extensions.md | 9 +++-- src/CodexAcpServer.ts | 5 ++- src/CodexEventHandler.ts | 4 +- src/ThreadGoalSnapshot.ts | 13 +++++-- .../CodexACPAgent/initialize.test.ts | 18 +++++++++ .../CodexACPAgent/thread-goal-events.test.ts | 37 ++++++++++++++++++- src/tool-calls/ClientCapabilities.ts | 11 +++++- 7 files changed, 84 insertions(+), 13 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 79f4a5935..6ef1b3cf3 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -640,7 +640,8 @@ The final update of that tool call puts the decision text in `rawOutput`. The goal extension exposes a long-running, session-scoped objective. It is shaped like a possible future first-class ACP API. The adapter sends no other goal key. -Only AIR gets the goal capability and the goal snapshots. +AIR gets the goal capability and snapshots as before. +A client that is not AIR can opt in by declaring `clientCapabilities._meta.goal: {}`. Another client gets no goal key and no `session_info_update` for a goal. ### Capability @@ -653,6 +654,8 @@ The `initialize` response advertises the goal support: `actions` is the subset of `set`, `pause`, `resume`, and `clear` that the adapter supports. A client must not assume support for an action that is not advertised. +AIR receives the capability in `_meta.jetbrains.air.goal`. +An opted-in client that is not AIR receives it in `_meta.goal`. ### Control request @@ -663,7 +666,8 @@ The adapter still accepts `_codex/session/goal_control` as a legacy alias. It do ### Session state -The adapter publishes the current snapshot in `session_info_update._meta.jetbrains.air.goal`. +The adapter publishes the current snapshot in `session_info_update._meta.jetbrains.air.goal` for AIR +and `session_info_update._meta.goal` for an opted-in client that is not AIR. Clearing a goal publishes `goal: null`. ```json @@ -1005,7 +1009,6 @@ AIR gets only the new key. A client that is not AIR gets neither the old key nor | Old key | New key | | --- | --- | | `agent_message_chunk._meta.codex.phase` | `_meta.jetbrains.air.phase`, same values | -| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape | | mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` | | available command `_meta.commandAction` | `_meta.jetbrains.air.commandAction` | | tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` | diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 5590c6338..bee24b24a 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -468,6 +468,9 @@ export class CodexAcpServer { steering: { supported: true, }, + ...(this.capabilities.goal && !this.capabilities.airClient ? { + [AIR_GOAL_KEY]: goalCapability, + } : {}), // Only AIR gets the AIR extension, see `docs/air-extensions.md`. ...(this.capabilities.airClient ? { [JETBRAINS_META_KEY]: { @@ -2116,7 +2119,7 @@ export class CodexAcpServer { return; } sessionState.currentGoal = snapshot; - const update = goalSessionInfoUpdate(snapshot, sessionState.clientCapabilities.airClient); + const update = goalSessionInfoUpdate(snapshot, sessionState.clientCapabilities); if (update === null) return; await new ACPSessionConnection(this.connection, sessionState.sessionId).update(update); } diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index c3ce6fd61..1e244aa39 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -738,7 +738,7 @@ export class CodexEventHandler { } this.sessionState.currentGoal = goalSnapshot; - return goalSessionInfoUpdate(goalSnapshot, this.sessionState.clientCapabilities.airClient); + return goalSessionInfoUpdate(goalSnapshot, this.sessionState.clientCapabilities); } private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { @@ -748,7 +748,7 @@ export class CodexEventHandler { } this.sessionState.currentGoal = null; - return goalSessionInfoUpdate(null, this.sessionState.clientCapabilities.airClient); + return goalSessionInfoUpdate(null, this.sessionState.clientCapabilities); } private createReasoningDeltaEvent( diff --git a/src/ThreadGoalSnapshot.ts b/src/ThreadGoalSnapshot.ts index 516880345..eeb82e9bc 100644 --- a/src/ThreadGoalSnapshot.ts +++ b/src/ThreadGoalSnapshot.ts @@ -2,6 +2,7 @@ import type {UpdateSessionEvent} from "./ACPSessionConnection"; import {AIR_GOAL_KEY, withAirMeta} from "./AirExtension"; import {GOAL_CONTROL_METHOD, type GoalSnapshot, type GoalStatus} from "./GoalExtension"; import type {ThreadGoal} from "./app-server/v2"; +import type {ClientCapabilities} from "./tool-calls/ClientCapabilities"; export type ThreadGoalSnapshot = GoalSnapshot; @@ -47,11 +48,15 @@ export function sameThreadGoalSnapshot( && left.createdAt === right.createdAt; } -/** Only AIR gets the goal. The update carries nothing else, so another client gets no update. */ -export function goalSessionInfoUpdate(goal: ThreadGoalSnapshot | null, airClient: boolean): UpdateSessionEvent | null { - if (!airClient) return null; +export function goalSessionInfoUpdate( + goal: ThreadGoalSnapshot | null, + capabilities: ClientCapabilities, +): UpdateSessionEvent | null { + if (!capabilities.goal) return null; return { sessionUpdate: "session_info_update", - _meta: withAirMeta(undefined, AIR_GOAL_KEY, goal), + _meta: capabilities.airClient + ? withAirMeta(undefined, AIR_GOAL_KEY, goal) + : {[AIR_GOAL_KEY]: goal}, }; } diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 45e2bd675..720f3aa4e 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -97,6 +97,24 @@ describe('CodexACPAgent - initialize', () => { }); }); + it('advertises the goal extension to a non-AIR client that opts in', async () => { + const result = await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {_meta: {goal: {}}}, + }); + + expect(result._meta).toEqual({ + steering: { + supported: true, + }, + goal: { + version: 1, + controlMethod: "_session/goal", + actions: ["set", "pause", "resume", "clear"], + }, + }); + }); + it('should advertise gateway auth when the client opts into gateway auth metadata', async () => { const params: acp.InitializeRequest = { protocolVersion: acp.PROTOCOL_VERSION, diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index 07220ed88..8e3cf2cff 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { SessionState } from "../../CodexAcpServer"; import type { ServerNotification } from "../../app-server"; import { AgentMode } from "../../AgentMode"; +import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; import { createCodexMockTestFixture, createTestSessionState, @@ -44,6 +45,39 @@ describe("CodexEventHandler - thread goal events", () => { ); }); + it("sends goal updates to a non-AIR client that opts in", async () => { + const goalUpdatedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 12, + createdAt: 1710000000, + updatedAt: 1710000012, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState({ + clientCapabilities: ClientCapabilities.from({_meta: {goal: {}}}), + }), [goalUpdatedNotification]); + + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(1); + expect(events[0]!.args[0].update._meta).toEqual({ + goal: expect.objectContaining({ + objective: "Ship the goal update", + status: "active", + }), + }); + }); + it("should trim multiline thread goal objectives in session metadata", async () => { const goalUpdatedNotification: ServerNotification = { method: "thread/goal/updated", @@ -262,11 +296,12 @@ describe("CodexEventHandler - thread goal events", () => { }); }); - function createSessionState(): SessionState { + function createSessionState(overrides: Partial = {}): SessionState { return createTestSessionState({ sessionId, currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE, + ...overrides, }); } }); diff --git a/src/tool-calls/ClientCapabilities.ts b/src/tool-calls/ClientCapabilities.ts index 2e8977241..e32a7231f 100644 --- a/src/tool-calls/ClientCapabilities.ts +++ b/src/tool-calls/ClientCapabilities.ts @@ -22,6 +22,7 @@ export type AirCapabilities = { type ClientCapabilityValues = { readonly airClient: boolean; + readonly goal: boolean; readonly terminalOutput: boolean; readonly terminalOutputDelta: boolean; readonly planUpdates: boolean; @@ -38,6 +39,7 @@ type ClientCapabilityValues = { export class ClientCapabilities { static readonly DEFAULT = new ClientCapabilities({ airClient: false, + goal: false, terminalOutput: false, terminalOutputDelta: false, planUpdates: false, @@ -46,6 +48,8 @@ export class ClientCapabilities { /** The client declares `_meta.jetbrains.air`. */ readonly airClient: boolean; + /** The client is AIR or declares `_meta.goal`. */ + readonly goal: boolean; /** The client declares `_meta.terminal_output`, the Zed convention for command output chunks. */ readonly terminalOutput: boolean; /** The client declares `_meta.terminal_output_delta` and appends the output chunks. */ @@ -56,6 +60,7 @@ export class ClientCapabilities { private constructor(values: ClientCapabilityValues) { this.airClient = values.airClient; + this.goal = values.goal; this.terminalOutput = values.terminalOutput; this.terminalOutputDelta = values.terminalOutputDelta; this.planUpdates = values.planUpdates; @@ -63,8 +68,11 @@ export class ClientCapabilities { } static from(capabilities: acp.ClientCapabilities | null | undefined): ClientCapabilities { + const airClient = isAirClient(capabilities); + const goal = capabilities?._meta?.["goal"]; return new ClientCapabilities({ - airClient: isAirClient(capabilities), + airClient, + goal: airClient || (goal !== null && typeof goal === "object" && !Array.isArray(goal)), terminalOutput: capabilities?._meta?.["terminal_output"] === true, terminalOutputDelta: capabilities?._meta?.["terminal_output_delta"] === true, planUpdates: capabilities?.plan != null, @@ -91,4 +99,3 @@ export class ClientCapabilities { return this.airClient ? null : "terminal_output_delta"; } } -