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
9 changes: 6 additions & 3 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
5 changes: 4 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
13 changes: 9 additions & 4 deletions src/ThreadGoalSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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},
};
}
18 changes: 18 additions & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion src/__tests__/CodexACPAgent/thread-goal-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -262,11 +296,12 @@ describe("CodexEventHandler - thread goal events", () => {
});
});

function createSessionState(): SessionState {
function createSessionState(overrides: Partial<SessionState> = {}): SessionState {
return createTestSessionState({
sessionId,
currentModelId: "model-id[effort]",
agentMode: AgentMode.DEFAULT_AGENT_MODE,
...overrides,
});
}
});
11 changes: 9 additions & 2 deletions src/tool-calls/ClientCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AirCapabilities = {

type ClientCapabilityValues = {
readonly airClient: boolean;
readonly goal: boolean;
readonly terminalOutput: boolean;
readonly terminalOutputDelta: boolean;
readonly planUpdates: boolean;
Expand All @@ -38,6 +39,7 @@ type ClientCapabilityValues = {
export class ClientCapabilities {
static readonly DEFAULT = new ClientCapabilities({
airClient: false,
goal: false,
terminalOutput: false,
terminalOutputDelta: false,
planUpdates: false,
Expand All @@ -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. */
Expand All @@ -56,15 +60,19 @@ 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;
this.air = values.air;
}

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,
Expand All @@ -91,4 +99,3 @@ export class ClientCapabilities {
return this.airClient ? null : "terminal_output_delta";
}
}