Skip to content
Merged
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
18 changes: 11 additions & 7 deletions src/ACPSessionConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {toV2ReplayUpdates} from "./AcpV2Replay";
import {toV1RequestPermissionResponse, toV2RequestPermissionRequest} from "./AcpV2Permissions";
import {elicitationSessionId, toV1CreateElicitationResponse, toV2CreateElicitationRequest} from "./AcpV2Elicitation";
import {AcpV2ForegroundState} from "./AcpV2ForegroundState";
import {AcpV2SessionInfo, type SessionMetadataPatch} from "./AcpV2SessionInfo";
import {AcpV2SessionInfo, type SessionInfoFields, type SessionMetadataPatch} from "./AcpV2SessionInfo";
import {type AcpClientDelivery, clientNotificationSessionId, clientRequestSessionId} from "./AcpClientDelivery";

export type AcpClientConnection = Pick<acp.AgentContext, "notify" | "request">;
Expand Down Expand Up @@ -120,8 +120,8 @@ export class AcpV2Connection {
return this.sessionInfoFor(sessionId).snapshot(title);
}

patchSessionMetadata(sessionId: string, change: SessionMetadataPatch): Promise<void> {
return this.sessionInfoFor(sessionId).patchMetadata(change);
patchSessionMetadata(sessionId: string, change: SessionMetadataPatch, fields: SessionInfoFields = {}): Promise<void> {
return this.sessionInfoFor(sessionId).patchMetadata(change, fields);
}

startForegroundItem(sessionId: string, itemId: string): Promise<void> {
Expand All @@ -137,12 +137,16 @@ export class AcpV2Connection {
}

forgetSession(sessionId: string): void {
this.foreground.get(sessionId)?.dispose();
this.foreground.delete(sessionId);
this.forgetForegroundState(sessionId);
this.sessionInfo.get(sessionId)?.dispose();
this.sessionInfo.delete(sessionId);
}

forgetForegroundState(sessionId: string): void {
this.foreground.get(sessionId)?.dispose();
this.foreground.delete(sessionId);
}

private sessionInfoFor(sessionId: string, metadata: Record<string, unknown> | null = null): AcpV2SessionInfo {
if (this.sessionDelivery && !this.sessionDelivery.canPublishSession(sessionId)) {
const closed = new AcpV2SessionInfo(async () => {});
Expand Down Expand Up @@ -343,8 +347,8 @@ export class ACPSessionConnection {
forSession(sessionId: string): ACPSessionConnection { return new ACPSessionConnection(this.connection, sessionId); }

/** Domain writers can rebuild an owned metadata value without changing v2's shallow patch semantics. */
patchSessionMetadata(change: SessionMetadataPatch): Promise<void> {
return this.v2Connection?.patchSessionMetadata(this.sessionId, change) ?? Promise.resolve();
patchSessionMetadata(change: SessionMetadataPatch, fields: SessionInfoFields = {}): Promise<void> {
return this.v2Connection?.patchSessionMetadata(this.sessionId, change, fields) ?? Promise.resolve();
}

/** Replay shares live report filtering, with v2 entity initialization after conversion. */
Expand Down
6 changes: 4 additions & 2 deletions src/AcpV2SessionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {isRecord} from "./permissions/json";
type Update = acp.SessionInfoUpdate & {sessionUpdate: "session_info_update"};
type NotificationMetadata = acp.UpdateSessionNotification["_meta"];
export type SessionMetadataPatch = (current: Readonly<Record<string, unknown>>) => Record<string, unknown> | null;
export type SessionInfoFields = Omit<acp.SessionInfoUpdate, "_meta">;

/** Owns current session metadata and orders live patches against resume resynchronization. */
export class AcpV2SessionInfo {
Expand All @@ -28,12 +29,13 @@ export class AcpV2SessionInfo {
});
}

patchMetadata(change: SessionMetadataPatch): Promise<void> {
patchMetadata(change: SessionMetadataPatch, fields: SessionInfoFields = {}): Promise<void> {
const copy: SessionInfoFields = JSON.parse(JSON.stringify(fields));
return this.enqueue(async () => {
const current = JSON.parse(JSON.stringify(Object.fromEntries(this.metadata))) as Record<string, unknown>;
const patch = change(current);
if (patch === null) return;
const update: Update = JSON.parse(JSON.stringify({sessionUpdate: "session_info_update", _meta: patch}));
const update: Update = JSON.parse(JSON.stringify({...copy, sessionUpdate: "session_info_update", _meta: patch}));
this.applyMetadata(update._meta);
await this.publish(update);
});
Expand Down
21 changes: 20 additions & 1 deletion src/AirExtension.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {ClientCapabilities} from "@agentclientprotocol/sdk/unstable";
import type {ACPSessionConnection} from "./ACPSessionConnection";

/**
* Wire names for the versioned JetBrains AIR ACP extension.
Expand Down Expand Up @@ -46,6 +47,14 @@ export function withAirMeta(
meta: Record<string, unknown> | null | undefined,
key: string,
value: unknown,
): Record<string, unknown> {
return withAirMetadata(meta, {[key]: value});
}

/** Compose AIR-owned fields; this is a producer operation, not a protocol metadata merge. */
export function withAirMetadata(
meta: Record<string, unknown> | null | undefined,
fields: Record<string, unknown>,
): Record<string, unknown> {
const root = asRecord(meta);
const jetbrains = asRecord(root[JETBRAINS_META_KEY]);
Expand All @@ -57,12 +66,22 @@ export function withAirMeta(
[AIR_META_KEY]: {
...air,
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[key]: value,
...fields,
},
},
};
}

/** A v2 session patch replaces the whole JetBrains value, so compose it at publication time. */
export function publishAirSessionMetadata(session: ACPSessionConnection, key: string, value: unknown): Promise<void> {
if (session.protocolVersion === 2) {
return session.patchSessionMetadata(current => ({
[JETBRAINS_META_KEY]: withAirMeta(current, key, value)[JETBRAINS_META_KEY],
}));
}
return session.update({sessionUpdate: "session_info_update", _meta: withAirMeta(undefined, key, value)});
}

/**
* The metadata of a key that exists only for AIR.
* AIR gets `_meta.jetbrains.air.<key>`. Every other client gets no metadata.
Expand Down
33 changes: 14 additions & 19 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ import {
createUserMessageChunk,
} from "./ContentChunks";
import {
goalSessionInfoUpdate,
publishGoalSessionInfo,
sameThreadGoalSnapshot,
type ThreadGoalSnapshot,
toThreadGoalSnapshot,
Expand Down Expand Up @@ -217,6 +217,7 @@ import {
AIR_SESSION_FAILURE_KEY,
clientSupportsAirCapability,
JETBRAINS_META_KEY,
publishAirSessionMetadata,
} from "./AirExtension";
import {SessionIndexService} from "./SessionIndexService";
import type {SessionIndexTitleState} from "./SessionIndexTitles";
Expand Down Expand Up @@ -1238,7 +1239,7 @@ export class CodexAcpServer {
private endSessionCloseFence(sessionId: string): void {
const fence = this.closingSessions.get(sessionId);
if (!fence || --fence.count > 0) return;
for (const closed of fence.closedSessions) this.clientDelivery.closeSession(closed);
for (const closed of fence.closedSessions) this.sealSessionDelivery(closed);
this.closingSessions.delete(sessionId);
}

Expand All @@ -1247,10 +1248,15 @@ export class CodexAcpServer {
const fence = this.closingSessions.get(sessionId);
for (const closed of [sessionId, ...children]) {
if (fence) fence.closedSessions.add(closed);
else this.clientDelivery.closeSession(closed);
else this.sealSessionDelivery(closed);
}
}

private sealSessionDelivery(sessionId: string): void {
this.clientDelivery.closeSession(sessionId);
AcpV2Connection.of(this.connection)?.forgetSession(sessionId);
}

private getSessionGeneration(sessionId: string): number {
return this.sessionGenerations.get(sessionId) ?? 0;
}
Expand Down Expand Up @@ -2092,7 +2098,7 @@ export class CodexAcpServer {
this.sessions.delete(params.sessionId);
this.asyncQuestions.get(params.sessionId)?.dispose();
this.asyncQuestions.delete(params.sessionId);
AcpV2Connection.of(this.connection)?.forgetSession(params.sessionId);
AcpV2Connection.of(this.connection)?.forgetForegroundState(params.sessionId);
this.mcpSessionStartup.close(params.sessionId);
this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId);
this.pendingTurnStarts.delete(params.sessionId);
Expand Down Expand Up @@ -3399,9 +3405,8 @@ export class CodexAcpServer {
return;
}
sessionState.currentGoal = snapshot;
const update = goalSessionInfoUpdate(snapshot, sessionState.clientCapabilities.airClient);
if (update === null) return;
await new ACPSessionConnection(this.connection, sessionState.sessionId).update(update);
await publishGoalSessionInfo(new ACPSessionConnection(this.connection, sessionState.sessionId),
snapshot, sessionState.clientCapabilities.airClient);
}

/** The models of a root session, for the configuration of its native subagent sessions. */
Expand Down Expand Up @@ -3982,17 +3987,7 @@ export class CodexAcpServer {
}
try {
const session = new ACPSessionConnection(this.connection, sessionState.sessionId);
await session.update({
sessionUpdate: "session_info_update",
_meta: {
[JETBRAINS_META_KEY]: {
[AIR_META_KEY]: {
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[AIR_AGENT_FILE_CHANGE_REPORT_KEY]: report,
},
},
},
});
await publishAirSessionMetadata(session, AIR_AGENT_FILE_CHANGE_REPORT_KEY, report);
} catch (error) {
logger.error("Failed to publish agent file-change report", error);
}
Expand Down Expand Up @@ -4751,7 +4746,7 @@ export class CodexAcpServer {
).finally(() => {
signal?.removeEventListener("abort", dropPendingRequest);
reservation.release();
if (!this.sessions.has(sessionId)) AcpV2Connection.of(this.connection)?.forgetSession(sessionId);
if (!this.sessions.has(sessionId)) AcpV2Connection.of(this.connection)?.forgetForegroundState(sessionId);
});
});
}
Expand Down
25 changes: 21 additions & 4 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
AIR_META_KEY,
AIR_SESSION_FAILURE_KEY,
JETBRAINS_META_KEY,
withAirMetadata,
} from "./AirExtension";
import type {SubagentEventRouter} from "./subagents/SubagentEventRouter";
import {StandardSubagentEventRouter} from "./subagents/StandardSubagentEventRouter";
Expand Down Expand Up @@ -335,7 +336,7 @@ export class CodexEventHandler {
await this.finishCompactionsForNotification(notification);
if (this.isAuthenticationRequiredError(notification.params.error.codexErrorInfo)) return;
if (notification.params.willRetry) {
await this.session.update(this.createSessionFailureUpdate(this.recordRetryWarning(notification.params, false)));
await this.publishUpdate(this.createSessionFailureUpdate(this.recordRetryWarning(notification.params, false)));
return;
}
const failure = this.recordSessionFailure(
Expand All @@ -346,7 +347,7 @@ export class CodexEventHandler {
undefined,
false,
);
await this.session.update(this.createSessionFailureUpdate(failure));
await this.publishUpdate(this.createSessionFailureUpdate(failure));
}

async flushPendingErrors(): Promise<void> {
Expand All @@ -357,7 +358,7 @@ export class CodexEventHandler {
for (const error of errors) {
const update = await this.createErrorEvent(error);
if (update) {
await this.session.update(update);
await this.publishUpdate(update);
}
}
}
Expand Down Expand Up @@ -484,7 +485,7 @@ export class CodexEventHandler {
if (ignoredBySubagents) return;
if (updateEvent === undefined) updateEvent = await this.createUpdateEvent(notification);
if (updateEvent) {
await this.session.update(updateEvent, this.subagents.notificationSessionId(notification));
await this.publishUpdate(updateEvent, this.subagents.notificationSessionId(notification));
}
}

Expand Down Expand Up @@ -796,6 +797,22 @@ export class CodexEventHandler {
}
}

/** These fact builders produce partial owned namespaces, unlike raw metadata patches. */
private publishUpdate(update: UpdateSessionEvent, sessionId = this.sessionState.sessionId): Promise<void> {
if (this.session.protocolVersion !== 2 || update.sessionUpdate !== "session_info_update" || update._meta == null) {
return this.session.update(update, sessionId);
}
const {_meta, sessionUpdate: _tag, ...fields} = update;
return this.session.forSession(sessionId).patchSessionMetadata(current => {
const metadata = {..._meta};
const codex = recordOrNull(_meta["codex"]);
if (codex) metadata["codex"] = {...recordOrNull(current["codex"]), ...codex};
const air = recordOrNull(recordOrNull(_meta[JETBRAINS_META_KEY])?.[AIR_META_KEY]);
if (air) metadata[JETBRAINS_META_KEY] = withAirMetadata(current, air)[JETBRAINS_META_KEY];
return metadata;
}, fields);
}

private createCodexSessionInfoUpdate(codexMetadata: Record<string, unknown>): UpdateSessionEvent {
return {
sessionUpdate: "session_info_update",
Expand Down
7 changes: 2 additions & 5 deletions src/SessionIndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import path from "node:path";
import type * as acp from "@agentclientprotocol/sdk/unstable";
import {RequestError} from "@agentclientprotocol/sdk/unstable";
import {ACPSessionConnection, type AcpClientConnection} from "./ACPSessionConnection";
import {clientSupportsAirCapability, withAirMeta} from "./AirExtension";
import {clientSupportsAirCapability, publishAirSessionMetadata} from "./AirExtension";
import type {ServerNotification} from "./app-server";
import type {CodexAcpClient} from "./CodexAcpClient";
import type {SessionState} from "./CodexAcpServer";
Expand Down Expand Up @@ -274,10 +274,7 @@ export class SessionIndexService {
});
// Tell the client why it closed before ending this operation's delivery fence.
if (archived ? open : this.host.session(sessionId) !== undefined) {
await new ACPSessionConnection(this.host.connection(), sessionId).update({
sessionUpdate: "session_info_update",
_meta: withAirMeta(undefined, AIR_ARCHIVED_KEY, archived),
});
await publishAirSessionMetadata(new ACPSessionConnection(this.host.connection(), sessionId), AIR_ARCHIVED_KEY, archived);
}
} finally {
if (archived) this.host.endSessionCloseFence(sessionId);
Expand Down
8 changes: 6 additions & 2 deletions src/ThreadGoalSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {UpdateSessionEvent} from "./ACPSessionConnection";
import {AIR_GOAL_KEY, withAirMeta} from "./AirExtension";
import type {ACPSessionConnection, UpdateSessionEvent} from "./ACPSessionConnection";
import {AIR_GOAL_KEY, publishAirSessionMetadata, withAirMeta} from "./AirExtension";
import {GOAL_CONTROL_METHOD, type GoalSnapshot, type GoalStatus} from "./GoalExtension";
import type {ThreadGoal} from "./app-server/v2";

Expand Down Expand Up @@ -55,3 +55,7 @@ export function goalSessionInfoUpdate(goal: ThreadGoalSnapshot | null, airClient
_meta: withAirMeta(undefined, AIR_GOAL_KEY, goal),
};
}

export function publishGoalSessionInfo(session: ACPSessionConnection, goal: ThreadGoalSnapshot | null, airClient: boolean): Promise<void> {
return airClient ? publishAirSessionMetadata(session, AIR_GOAL_KEY, goal) : Promise.resolve();
}
11 changes: 11 additions & 0 deletions src/__tests__/AcpV2SessionInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ describe("V2 session metadata", () => {
expect(sent.at(-1)?._meta).toEqual({ns: {version: 1, added: true}});
});

it("preserves accompanying session info fields while composing metadata", async () => {
const sent: Update[] = [];
const info = new AcpV2SessionInfo(async update => { sent.push(update); });
const fields = {title: "Current", updatedAt: "2026-10-30T12:00:00Z"};
const pending = info.patchMetadata(() => ({current: true}), fields);
fields.title = "Changed after publication";
await pending;
expect(sent).toEqual([{title: "Current", updatedAt: "2026-10-30T12:00:00Z",
sessionUpdate: "session_info_update", _meta: {current: true}}]);
});

it("stops queued and in-flight restoration after disposal", async () => {
const sent: Update[] = [];
const clearing = deferred();
Expand Down
Loading
Loading