Skip to content

Commit 2fd758c

Browse files
authored
feat: support AIR sessionIndex extension (spacering-net#590) v2 (spacering-net#622)
Support spacering-net#590 in ACP v2
1 parent 9e1690f commit 2fd758c

7 files changed

Lines changed: 348 additions & 2 deletions

File tree

‎docs/air-extensions.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ AIR uses `session/list` as its index of Codex threads when it declares `sessionI
10081008
Everything in this section applies only to such a client. The `initialize` answer to it lists `sessionIndex`,
10091009
`sessionArchive`, `sessionRename` and `sessionListSubscribe` in `_meta.jetbrains.air.capabilities`.
10101010
Another client, AIR included, keeps the old `session/list`, `session/delete` and `initialize` answers.
1011+
ACP v1 and v2 connections get the same session index.
10111012

10121013
The extension follows two ACP RFDs field for field: the session list extensions RFD (`limit`, order, row fields)
10131014
and [RFD #2161](https://github.com/agentclientprotocol/agent-client-protocol/pull/2161)

‎readme-dev.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ The v2 chain registers: `initialize`, `session/new|list|close|delete|resume` (v2
135135
into `session/resume` with `replayFrom`), `session/set_config_option`, `session/cancel`,
136136
`auth/login|logout`, `session/prompt` (plus `$/cancel_request`), `session/fork`, and
137137
`providers/list|set|disable`. Unstable extension methods on top of that: `_session/steering`,
138-
`_session/goal` (set/pause/resume/clear), and `_session/async_task/stop`. `session/fork` drops the
138+
`_session/goal` (set/pause/resume/clear), `_session/async_task/stop`, and the AIR session index
139+
requests (`_session/rename`, `_session/archive|unarchive`, `_session/list/subscribe|unsubscribe`).
140+
`session/fork` drops the
139141
v1 `modes` field (v2 removed the modes API in favor of config options), and forked sessions do not
140142
get an `available_commands_update`, matching v1. There are no new environment/config knobs specific
141143
to v2; it reuses the runtime environment variables above.
@@ -169,6 +171,9 @@ schema alone:
169171
offered there. A read, search or list command has no terminal; its output arrives as `content`
170172
text when it completes. A file change sends one diff per file whose Git patch is built from the
171173
Codex diff.
174+
- The AIR `sessionIndex` extension (`docs/air-extensions.md`, "Session index") works as on v1: a
175+
client that declares `sessionIndex` gets it, `session/list` reads `_meta.jetbrains.air.list`, and
176+
rows, `session_info_update`s and `_session/list/changes` carry the same `_meta`.
172177
- Other AIR `_meta` keys (goal, message phase, mode kind, permission presentation) go only to AIR
173178
clients, as on v1. Every v2 permission request still gets its prompt `title` and `description`.
174179

‎src/AcpAgentRouter.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,12 @@ export function createAcpAgentRouter(
181181
.onRequest(acpV2.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params))
182182
.onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params))
183183
.onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params))
184-
.onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params));
184+
.onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params))
185+
.onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent().sessionIndex.rename(ctx.params))
186+
.onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, true))
187+
.onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, false))
188+
.onRequest(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params))
189+
.onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params));
185190

186191
return acpV2.agentProtocolRouter().withV1(v1Agent).withV2(withOmittedParamsWorkaround(v2Agent));
187192
}

‎src/CodexAcpServer.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,9 @@ export class CodexAcpServer {
692692
this.reportingConnection.reports.compareMeta = this.capabilities.airToolCallContract;
693693
// Boolean config options are baseline on v2, so there is nothing to probe.
694694
this.booleanConfigOptionsSupported = true;
695+
this.sessionIndex.configure(clientCapabilities);
695696
await this.runWithProcessCheck(() => this.codexAcpClient.initialize({clientInfo: params.info}));
697+
this.sessionIndex.observe(this.codexAcpClient);
696698
this.publishFirstAuthStatusAfterResponse();
697699
return {
698700
protocolVersion: 2,
@@ -755,6 +757,7 @@ export class CodexAcpServer {
755757
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
756758
AIR_ASYNC_TASKS_KEY,
757759
AIR_RECOMMENDED_CONFIG_VALUE_KEY,
760+
...this.sessionIndex.agentCapabilities(),
758761
];
759762
return {
760763
steering: {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"listChanges": [
3+
{
4+
"subscriptionId": "<subscriptionId>",
5+
"sessions": [
6+
{
7+
"sessionId": "thread-1",
8+
"cwd": "/workspace",
9+
"title": "Renamed",
10+
"updatedAt": "1970-01-01T00:03:20.000Z",
11+
"_meta": {
12+
"jetbrains": {
13+
"air": {
14+
"version": 1,
15+
"archived": false,
16+
"lastPromptAt": "1970-01-01T00:05:00.000Z",
17+
"model": "gpt-5",
18+
"state": "running"
19+
}
20+
}
21+
}
22+
}
23+
],
24+
"removed": []
25+
}
26+
],
27+
"unsubscribe": {}
28+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
{
2+
"capabilities": [
3+
"sessionFailure",
4+
"agentFileChangeReport",
5+
"customInstructions",
6+
"nativeSubagentSessions",
7+
"asyncTasks",
8+
"recommendedValue",
9+
"sessionIndex",
10+
"sessionArchive",
11+
"sessionRename",
12+
"sessionListSubscribe"
13+
],
14+
"list": {
15+
"sessions": [
16+
{
17+
"sessionId": "thread-1",
18+
"cwd": "/workspace",
19+
"title": "Earlier session",
20+
"updatedAt": "1970-01-01T00:03:20.000Z",
21+
"_meta": {
22+
"jetbrains": {
23+
"air": {
24+
"version": 1,
25+
"archived": false,
26+
"lastPromptAt": "1970-01-01T00:05:00.000Z",
27+
"model": "gpt-5"
28+
}
29+
}
30+
}
31+
}
32+
],
33+
"nextCursor": null
34+
},
35+
"rename": {},
36+
"archive": {},
37+
"unarchive": {},
38+
"codexRequests": [
39+
{
40+
"method": "thread/list",
41+
"params": {
42+
"cursor": null,
43+
"limit": 10,
44+
"sortKey": "recency_at",
45+
"archived": false,
46+
"sourceKinds": [],
47+
"modelProviders": [],
48+
"cwd": "/workspace",
49+
"useStateDbOnly": true
50+
}
51+
},
52+
{
53+
"method": "thread/name/set",
54+
"params": {
55+
"threadId": "thread-1",
56+
"name": "Renamed"
57+
}
58+
},
59+
{
60+
"method": "thread/archive",
61+
"params": {
62+
"threadId": "thread-1"
63+
}
64+
},
65+
{
66+
"method": "thread/unarchive",
67+
"params": {
68+
"threadId": "thread-1"
69+
}
70+
}
71+
],
72+
"updates": [
73+
{
74+
"sessionId": "thread-1",
75+
"update": {
76+
"title": "Renamed",
77+
"sessionUpdate": "session_info_update"
78+
}
79+
},
80+
{
81+
"sessionId": "thread-1",
82+
"update": {
83+
"_meta": {
84+
"jetbrains": {
85+
"air": {
86+
"version": 1,
87+
"archived": true
88+
}
89+
}
90+
},
91+
"sessionUpdate": "session_info_update"
92+
}
93+
}
94+
]
95+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import {afterEach, describe, expect, it, vi} from 'vitest';
2+
import * as acp from '@agentclientprotocol/sdk';
3+
import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2';
4+
import {z} from 'zod';
5+
import {createAcpAgentRouter} from '../../AcpAgentRouter';
6+
import {CodexAcpServer} from '../../CodexAcpServer';
7+
import {CodexAcpClient} from '../../CodexAcpClient';
8+
import {CodexAppServerClient} from '../../CodexAppServerClient';
9+
import type {Thread} from '../../app-server/v2';
10+
import {
11+
SESSION_ARCHIVE_METHOD,
12+
SESSION_LIST_CHANGES_METHOD,
13+
SESSION_LIST_SUBSCRIBE_METHOD,
14+
SESSION_LIST_UNSUBSCRIBE_METHOD,
15+
SESSION_RENAME_METHOD,
16+
SESSION_UNARCHIVE_METHOD,
17+
} from '../../SessionIndex';
18+
import {createTestModel} from '../acp-test-utils';
19+
import {createMockConnections} from './test-utils';
20+
import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard';
21+
22+
const sessionId = "thread-1";
23+
const cwd = "/workspace";
24+
25+
function createThread(overrides?: Partial<Thread>): Thread {
26+
return {
27+
id: sessionId,
28+
sessionId,
29+
parentThreadId: null,
30+
threadSource: null,
31+
originator: null,
32+
forkedFromId: null,
33+
preview: "Earlier session",
34+
ephemeral: false,
35+
modelProvider: "openai",
36+
model: "gpt-5",
37+
reasoningEffort: null,
38+
createdAt: 100,
39+
updatedAt: 200,
40+
recencyAt: 300,
41+
status: {type: "notLoaded"},
42+
path: null,
43+
cwd,
44+
cliVersion: "0.0.0",
45+
section: null,
46+
sectionEnteredAt: null,
47+
projectId: null,
48+
historyMode: "paginated",
49+
source: "cli",
50+
agentNickname: null,
51+
agentRole: null,
52+
gitInfo: null,
53+
name: null,
54+
turns: [],
55+
...overrides,
56+
};
57+
}
58+
59+
function codexResponse(method: string): unknown {
60+
switch (method) {
61+
case "thread/start":
62+
return {
63+
thread: createThread({status: {type: "idle"}}),
64+
model: "gpt-5",
65+
modelProvider: "openai",
66+
reasoningEffort: "medium",
67+
serviceTier: null,
68+
};
69+
case "thread/read":
70+
return {thread: createThread({
71+
status: {type: "active", activeFlags: []},
72+
name: "Renamed",
73+
path: "/codex-home/sessions/rollout.jsonl",
74+
})};
75+
case "thread/list":
76+
return {data: [createThread()], nextCursor: null, backwardsCursor: null};
77+
case "model/list":
78+
return {data: [createTestModel({id: "gpt-5"})], nextCursor: null};
79+
case "skills/list":
80+
return {data: []};
81+
case "config/read":
82+
return {config: {}, origins: {}, layers: []};
83+
case "thread/goal/get":
84+
return {goal: null};
85+
default:
86+
return {};
87+
}
88+
}
89+
90+
/** Methods whose requests the snapshots record; the rest is session-setup noise. */
91+
const recordedCodexMethods = new Set([
92+
"thread/list",
93+
"thread/name/set",
94+
"thread/archive",
95+
"thread/unarchive",
96+
]);
97+
98+
async function connectV2Client(airCapabilities: string[]) {
99+
const mocks = createMockConnections();
100+
const codexRequests: Array<{method: string, params: unknown}> = [];
101+
mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: unknown) => {
102+
if (recordedCodexMethods.has(method)) {
103+
codexRequests.push({method, params});
104+
}
105+
return codexResponse(method);
106+
});
107+
const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any));
108+
vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null});
109+
vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai");
110+
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
111+
vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []});
112+
const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient));
113+
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>();
114+
const agentToClient = new TransformStream<Uint8Array, Uint8Array>();
115+
router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable));
116+
117+
const updates: acpV2.UpdateSessionNotification[] = [];
118+
const listChanges: unknown[] = [];
119+
const connection = acpV2.client({name: "test-client"})
120+
.onNotification(acpV2.methods.client.session.update, (ctx) => {
121+
checkV2SessionUpdate(ctx.params.update);
122+
updates.push(ctx.params);
123+
})
124+
.onNotification(SESSION_LIST_CHANGES_METHOD, z.object({}).passthrough(), (ctx) => {
125+
listChanges.push(ctx.params);
126+
})
127+
.connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable));
128+
const initialize = await connection.agent.request(acpV2.methods.agent.initialize, {
129+
protocolVersion: 2,
130+
info: {name: "test-client", version: "1.0.0"},
131+
capabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: airCapabilities}}}},
132+
});
133+
const sendServerNotification = (notification: unknown) => mocks.getUnhandledNotificationHandler()?.(notification);
134+
return {connection, initialize, codexRequests, updates, listChanges, sendServerNotification};
135+
}
136+
137+
function airCapabilitiesOf(response: acpV2.InitializeResponse): unknown {
138+
return (response._meta as any)?.jetbrains?.air?.capabilities ?? null;
139+
}
140+
141+
function dump(value: unknown): string {
142+
return `${JSON.stringify(value, null, 2)}\n`;
143+
}
144+
145+
describe('AIR sessionIndex over ACP v2', () => {
146+
let closeClient: (() => void) | null = null;
147+
148+
afterEach(() => {
149+
closeClient?.();
150+
closeClient = null;
151+
vi.useRealTimers();
152+
vi.clearAllMocks();
153+
expectConformingV2SessionUpdates();
154+
});
155+
156+
it('neither advertises nor serves sessionIndex to a v2 client that does not declare it', async () => {
157+
const {connection, initialize} = await connectV2Client([]);
158+
closeClient = () => connection.close();
159+
160+
expect(airCapabilitiesOf(initialize)).not.toContain("sessionIndex");
161+
await expect(connection.agent.request(SESSION_RENAME_METHOD, {sessionId, title: "Renamed"}))
162+
.rejects.toMatchObject({code: -32601});
163+
});
164+
165+
it('lists, renames, archives and unarchives sessions', async () => {
166+
const {connection, initialize, codexRequests, updates} = await connectV2Client(["sessionIndex"]);
167+
closeClient = () => connection.close();
168+
await connection.agent.request(acpV2.methods.agent.session.new, {cwd});
169+
await vi.waitFor(() => expect(updates.length).toBeGreaterThan(0));
170+
updates.length = 0;
171+
172+
const list = await connection.agent.request(acpV2.methods.agent.session.list, {
173+
cwd,
174+
_meta: {jetbrains: {air: {list: {limit: 10}}}},
175+
});
176+
const rename = await connection.agent.request(SESSION_RENAME_METHOD, {sessionId, title: "Renamed"});
177+
// The session is open here: archiving closes it and tells the client why.
178+
const archive = await connection.agent.request(SESSION_ARCHIVE_METHOD, {sessionId});
179+
const unarchive = await connection.agent.request(SESSION_UNARCHIVE_METHOD, {sessionId});
180+
181+
await expect(dump({
182+
capabilities: airCapabilitiesOf(initialize),
183+
list,
184+
rename,
185+
archive,
186+
unarchive,
187+
codexRequests,
188+
updates: updates.filter(({update}) => update.sessionUpdate === "session_info_update"),
189+
})).toMatchFileSnapshot('data/session-index-v2.json');
190+
});
191+
192+
it('pushes the changed rows of a subscription', async () => {
193+
const {connection, listChanges, sendServerNotification} = await connectV2Client(["sessionIndex"]);
194+
closeClient = () => connection.close();
195+
196+
const {subscriptionId} = await connection.agent.request(SESSION_LIST_SUBSCRIBE_METHOD, {cwd}) as {subscriptionId: string};
197+
sendServerNotification({
198+
method: "thread/status/changed",
199+
params: {threadId: sessionId, status: {type: "active", activeFlags: []}},
200+
});
201+
await vi.waitFor(() => expect(listChanges).toHaveLength(1));
202+
const unsubscribe = await connection.agent.request(SESSION_LIST_UNSUBSCRIBE_METHOD, {subscriptionId});
203+
204+
await expect(dump({
205+
listChanges: listChanges.map((change: any) => ({...change, subscriptionId: "<subscriptionId>"})),
206+
unsubscribe,
207+
})).toMatchFileSnapshot('data/session-index-v2-subscription.json');
208+
});
209+
});

0 commit comments

Comments
 (0)