diff --git a/src/ACPSessionConnection.ts b/src/ACPSessionConnection.ts index c3045a0e..8688d468 100644 --- a/src/ACPSessionConnection.ts +++ b/src/ACPSessionConnection.ts @@ -10,6 +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 {type AcpClientDelivery, clientNotificationSessionId, clientRequestSessionId} from "./AcpClientDelivery"; export type AcpClientConnection = Pick; @@ -23,6 +24,7 @@ export class AcpV2Connection { readonly client: AcpV2ClientConnection; private isTurnRunning: (sessionId: string) => boolean = () => true; private readonly foreground = new Map(); + private readonly sessionInfo = new Map(); private sessionDelivery: AcpClientDelivery | null = null; private sendElicitationCompletion: (params: unknown) => Promise; @@ -34,6 +36,10 @@ export class AcpV2Connection { if (method === acpV2.methods.client.elicitation.complete) return this.sendElicitationCompletion(params); const sessionId = clientNotificationSessionId(method, params); if (sessionId !== null && this.sessionDelivery && !this.sessionDelivery.canPublishSession(sessionId)) return Promise.resolve(); + if (sessionId !== null) { + const {update, _meta} = params as acpV2.UpdateSessionNotification; + if (acpV2.SessionUpdate.isSessionInfoUpdate(update)) return this.sessionInfoFor(sessionId).update(update, _meta); + } return client.notify(method as `_${string}`, params); }, request: (method: string, params?: unknown, options?: acpV2.SendRequestOptions) => { @@ -80,9 +86,10 @@ export class AcpV2Connection { * Sends a session update, rendered in the v2 wire shape, through the v2 `session/update` * binding. One update can render to several v2 updates (or none), sent in order. */ - async updateSession(sessionId: string, update: AcpSessionUpdate): Promise { + async updateSession(sessionId: string, update: AcpSessionUpdate, metadata?: acpV2.UpdateSessionNotification["_meta"]): Promise { for (const v2Update of toV2SessionUpdates(update)) { - await this.client.notify(acpV2.methods.client.session.update, {sessionId, update: v2Update}); + await this.client.notify(acpV2.methods.client.session.update, + {sessionId, update: v2Update, ...(metadata !== undefined ? {_meta: metadata} : {})}); } } @@ -99,6 +106,24 @@ export class AcpV2Connection { return this.foregroundFor(sessionId).snapshot(); } + resetSessionInfo(sessionId: string, metadata: Record | null): void { + this.sessionInfo.get(sessionId)?.dispose(); + this.sessionInfo.delete(sessionId); + this.sessionInfoFor(sessionId, metadata); + } + + refreshSessionInfo(sessionId: string, metadata: Record | null): Promise { + return metadata ? this.sessionInfoFor(sessionId).seedMetadata(metadata) : Promise.resolve(); + } + + snapshotSessionInfo(sessionId: string, title: string | null): Promise { + return this.sessionInfoFor(sessionId).snapshot(title); + } + + patchSessionMetadata(sessionId: string, change: SessionMetadataPatch): Promise { + return this.sessionInfoFor(sessionId).patchMetadata(change); + } + startForegroundItem(sessionId: string, itemId: string): Promise { return this.foregroundFor(sessionId).startItem(itemId); } @@ -114,6 +139,26 @@ export class AcpV2Connection { forgetSession(sessionId: string): void { this.foreground.get(sessionId)?.dispose(); this.foreground.delete(sessionId); + this.sessionInfo.get(sessionId)?.dispose(); + this.sessionInfo.delete(sessionId); + } + + private sessionInfoFor(sessionId: string, metadata: Record | null = null): AcpV2SessionInfo { + if (this.sessionDelivery && !this.sessionDelivery.canPublishSession(sessionId)) { + const closed = new AcpV2SessionInfo(async () => {}); + closed.dispose(); + return closed; + } + let info = this.sessionInfo.get(sessionId); + if (!info) { + info = new AcpV2SessionInfo((update, notificationMetadata) => { + if (this.sessionDelivery && !this.sessionDelivery.canPublishSession(sessionId)) return Promise.resolve(); + return this.transportClient.notify(acpV2.methods.client.session.update, + {sessionId, update, ...(notificationMetadata !== undefined ? {_meta: notificationMetadata} : {})}); + }, metadata); + this.sessionInfo.set(sessionId, info); + } + return info; } private foregroundFor(sessionId: string, running = false): AcpV2ForegroundState { @@ -216,7 +261,7 @@ export class AcpV2Connection { } if (method === acp.methods.client.session.update) { const notification = params as AcpSessionNotification; - return this.updateSession(notification.sessionId, notification.update); + return this.updateSession(notification.sessionId, notification.update, notification._meta); } if (method === acp.methods.client.elicitation.complete) { return this.completeElicitation(params as acp.CompleteElicitationNotification); @@ -297,6 +342,11 @@ 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 { + return this.v2Connection?.patchSessionMetadata(this.sessionId, change) ?? Promise.resolve(); + } + /** Replay shares live report filtering, with v2 entity initialization after conversion. */ replayUpdate(update: UpdateSessionEvent, initialized: Set): Promise { return this.v2Connection diff --git a/src/AcpV2SessionInfo.ts b/src/AcpV2SessionInfo.ts new file mode 100644 index 00000000..749fd312 --- /dev/null +++ b/src/AcpV2SessionInfo.ts @@ -0,0 +1,81 @@ +import type * as acp from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import {isRecord} from "./permissions/json"; + +type Update = acp.SessionInfoUpdate & {sessionUpdate: "session_info_update"}; +type NotificationMetadata = acp.UpdateSessionNotification["_meta"]; +export type SessionMetadataPatch = (current: Readonly>) => Record | null; + +/** Owns current session metadata and orders live patches against resume resynchronization. */ +export class AcpV2SessionInfo { + private readonly metadata = new Map(); + private tail = Promise.resolve(); + private disposed = false; + + constructor( + private readonly send: (update: Update, notificationMetadata?: NotificationMetadata) => Promise, + initialMetadata: Record | null = null, + ) { + this.applyMetadata(JSON.parse(JSON.stringify({_meta: initialMetadata}))._meta); + } + + update(update: Update, notificationMetadata?: NotificationMetadata): Promise { + let copy: {update: Update; _meta?: NotificationMetadata}; + try { copy = JSON.parse(JSON.stringify({update, _meta: notificationMetadata})); } + catch (error) { return Promise.reject(error); } + return this.enqueue(async () => { + this.applyMetadata(copy.update._meta); + await this.publish(copy.update, copy._meta); + }); + } + + patchMetadata(change: SessionMetadataPatch): Promise { + return this.enqueue(async () => { + const current = JSON.parse(JSON.stringify(Object.fromEntries(this.metadata))) as Record; + const patch = change(current); + if (patch === null) return; + const update: Update = JSON.parse(JSON.stringify({sessionUpdate: "session_info_update", _meta: patch})); + this.applyMetadata(update._meta); + await this.publish(update); + }); + } + + seedMetadata(metadata: Record): Promise { + const copy: unknown = JSON.parse(JSON.stringify({_meta: metadata}))._meta; + return this.enqueue(async () => { this.applyMetadata(copy); }); + } + + snapshot(title: string | null): Promise { + return this.enqueue(async () => { + // The client may hold keys this connection has never seen. + await this.publish({sessionUpdate: "session_info_update", title, _meta: null}); + if (this.metadata.size > 0) { + await this.publish({sessionUpdate: "session_info_update", _meta: Object.fromEntries(this.metadata)}); + } + }); + } + + dispose(): void { + this.disposed = true; + this.metadata.clear(); + } + + private applyMetadata(patch: unknown): void { + if (patch === null) this.metadata.clear(); + else if (isRecord(patch)) for (const [key, value] of Object.entries(patch)) { + if (value === null) this.metadata.delete(key); + else this.metadata.set(key, value); + } + } + + private enqueue(operation: () => Promise): Promise { + const next = this.tail.then(async () => { + if (!this.disposed) await operation(); + }); + this.tail = next.catch(() => {}); + return next; + } + + private publish(update: Update, notificationMetadata?: NotificationMetadata): Promise { + return this.disposed ? Promise.resolve() : this.send(update, notificationMetadata); + } +} diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index b4d03821..b8350352 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1407,7 +1407,7 @@ export class CodexAcpServer { this.sessionIndex.titleWriter(sessionId), ); sessionState.promptSuggestions = this.createPromptSuggestions(sessionState); - this.installSessionState(sessionState); + this.installSessionState(sessionState, sessionMetadata.threadStatus); AcpV2Connection.of(this.connection)?.restoreForegroundState(sessionId, sessionMetadata.threadStatus?.type === "active"); this.recovery?.markLoaded(sessionState, openGeneration); if (settleTrackerReady) { @@ -1593,13 +1593,24 @@ export class CodexAcpServer { ); } - private installSessionState(sessionState: SessionState): void { + private installSessionState(sessionState: SessionState, threadStatus?: SessionMetadata["threadStatus"]): void { const previous = this.sessions.get(sessionState.sessionId); previous?.asyncTasks.clear(); // The new state holds no suggestion, so the old one must drop its result in flight and clear what it published. void previous?.promptSuggestions?.clear(); this.sessions.set(sessionState.sessionId, sessionState); this.clientDelivery.openSession(sessionState.sessionId); + const v2 = AcpV2Connection.of(this.connection); + if (v2) { + const metadata = threadStatus ? {codex: {threadStatus}} : null; + // Seeding is queued before new live events; metadata belongs to the active session lifetime. + if (previous) { + if (previous.sessionFailure) sessionState.sessionFailure = previous.sessionFailure; + void v2.refreshSessionInfo(sessionState.sessionId, metadata); + } else { + v2.resetSessionInfo(sessionState.sessionId, metadata); + } + } } /** @@ -1962,10 +1973,7 @@ export class CodexAcpServer { // Drain live events released by setup/replay before taking the ordered snapshot. await this.codexAcpClient.waitForSessionNotifications(sessionId); const state = this.getSessionState(sessionId); - await new ACPSessionConnection(this.connection, sessionId).update({ - sessionUpdate: "session_info_update", - title: state.sessionTitle, - }); + await AcpV2Connection.of(this.connection)!.snapshotSessionInfo(sessionId, state.sessionTitle); await AcpV2Connection.of(this.connection)!.snapshotForegroundState(sessionId); } @@ -3567,7 +3575,7 @@ export class CodexAcpServer { this.sessionIndex.titleWriter(sessionId), ); sessionState.promptSuggestions = this.createPromptSuggestions(sessionState); - this.installSessionState(sessionState); + this.installSessionState(sessionState, thread.status); AcpV2Connection.of(this.connection)?.restoreForegroundState(sessionId, thread.status.type === "active"); this.recovery?.markLoaded(sessionState, openGeneration); if (knownAccount === "pending") { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5c429796..6a15f096 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -56,6 +56,7 @@ import { } from "./ContentChunks"; import {goalSessionInfoUpdate, sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; import {logger} from "./Logger"; +import {recordOrNull} from "./permissions/json"; import { AIR_EXTENSION_VERSION, AIR_EXTENSION_VERSION_KEY, @@ -372,7 +373,17 @@ export class CodexEventHandler { } async clearSessionFailure(): Promise { + const failure = this.sessionState.sessionFailure; delete this.sessionState.sessionFailure; + if (!failure || this.session.protocolVersion !== 2) return; + await this.session.patchSessionMetadata(metadata => { + const jetbrains = recordOrNull(metadata[JETBRAINS_META_KEY]); + const air = recordOrNull(jetbrains?.[AIR_META_KEY]); + const published = recordOrNull(air?.[AIR_SESSION_FAILURE_KEY]); + if (!air || published?.["id"] !== failure.id || published?.["revision"] !== failure.revision) return null; + const {[AIR_SESSION_FAILURE_KEY]: _failure, ...currentAir} = air; + return {[JETBRAINS_META_KEY]: {...jetbrains, [AIR_META_KEY]: currentAir}}; + }); } async completeSuccessfulTurn(turnId: string | null): Promise { @@ -381,7 +392,7 @@ export class CodexEventHandler { const active = this.sessionState.sessionFailure; if (active?.id !== this.activeFailureIdByScope.get(turnId) || active?.severity !== "warning") return; this.activeFailureIdByScope.delete(turnId); - delete this.sessionState.sessionFailure; + await this.clearSessionFailure(); } async handleFailedTurn(turn: Turn): Promise { @@ -1199,7 +1210,7 @@ export class CodexEventHandler { if (activeId === undefined || this.failuresById.get(activeId)?.severity !== "warning") return; this.activeFailureIdByScope.delete(turnId); if (this.sessionState.sessionFailure?.id === activeId) { - delete this.sessionState.sessionFailure; + void this.clearSessionFailure().catch(error => logger.error("Failed to clear session failure metadata", error)); } } diff --git a/src/__tests__/AcpV2SessionInfo.test.ts b/src/__tests__/AcpV2SessionInfo.test.ts new file mode 100644 index 00000000..0c30dfc0 --- /dev/null +++ b/src/__tests__/AcpV2SessionInfo.test.ts @@ -0,0 +1,121 @@ +import {describe, expect, it, vi} from "vitest"; +import {AcpV2SessionInfo} from "../AcpV2SessionInfo"; +import {AcpV2Connection, type AcpV2ClientConnection} from "../ACPSessionConnection"; +import {CodexEventHandler} from "../CodexEventHandler"; +import {createTestSessionState} from "./acp-test-utils"; +import type {SessionFailure} from "../CodexAcpServer"; + +type Update = Parameters[0]; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return {promise, resolve}; +} + +describe("V2 session metadata", () => { + it("uses shallow patches, explicit removals, and a clear before resynchronization", async () => { + const sent: Update[] = []; + const info = new AcpV2SessionInfo(async update => { sent.push(update); }); + await info.update({sessionUpdate: "session_info_update", _meta: {retained: true, removed: true, ns: {old: true}}}); + await info.update({sessionUpdate: "session_info_update", title: "Title"}); + await info.update({sessionUpdate: "session_info_update", _meta: {}}); + await info.update({sessionUpdate: "session_info_update", _meta: {removed: null, ns: {nested: null}}}); + const start = sent.length; + await info.snapshot("Current"); + expect(sent.slice(start)).toEqual([ + {sessionUpdate: "session_info_update", title: "Current", _meta: null}, + {sessionUpdate: "session_info_update", _meta: {retained: true, ns: {nested: null}}}, + ]); + await info.update({sessionUpdate: "session_info_update", _meta: null}); + const empty = sent.length; + await info.snapshot(null); + expect(sent.slice(empty)).toEqual([{sessionUpdate: "session_info_update", title: null, _meta: null}]); + }); + + it("copies JSON at publication and does not store notification metadata", async () => { + const sent: Array<{update: Update; metadata: unknown}> = []; + const info = new AcpV2SessionInfo(async (update, metadata) => { sent.push({update, metadata}); }); + const value = {nested: {count: 1}}; + const pending = info.update({sessionUpdate: "session_info_update", _meta: value}, {event: {literal: null}}); + value.nested.count = 2; + await pending; + expect(sent[0]?.metadata).toEqual({event: {literal: null}}); + await info.snapshot("Current"); + expect(sent.at(-1)).toEqual({ + update: {sessionUpdate: "session_info_update", _meta: {nested: {count: 1}}}, + metadata: undefined, + }); + }); + + it("does not interleave a newer live patch between a clear and its restoration", async () => { + const sent: Update[] = []; + const clearing = deferred(); + const release = deferred(); + const info = new AcpV2SessionInfo(async update => { + sent.push(update); + if (update._meta === null) { clearing.resolve(); await release.promise; } + }, {ns: {version: "old"}}); + const snapshot = info.snapshot("Current"); + await clearing.promise; + const live = info.update({sessionUpdate: "session_info_update", _meta: {ns: {version: "new"}}}); + release.resolve(); + await Promise.all([snapshot, live]); + expect(sent.map(update => update._meta)).toEqual([null, {ns: {version: "old"}}, {ns: {version: "new"}}]); + }); + + it("takes its snapshot after earlier queued patches, including domain metadata changes", async () => { + const sent: Update[] = []; + const info = new AcpV2SessionInfo(async update => { sent.push(update); }); + const live = info.update({sessionUpdate: "session_info_update", _meta: {ns: {version: 1}}}); + const patch = info.patchMetadata(current => ({ns: {...current["ns"] as object, added: true}})); + const snapshot = info.snapshot("Current"); + await Promise.all([live, patch, snapshot]); + expect(sent.at(-1)?._meta).toEqual({ns: {version: 1, added: true}}); + }); + + it("stops queued and in-flight restoration after disposal", async () => { + const sent: Update[] = []; + const clearing = deferred(); + const release = deferred(); + const info = new AcpV2SessionInfo(async update => { + sent.push(update); + clearing.resolve(); + await release.promise; + }, {old: true}); + const snapshot = info.snapshot("Current"); + await clearing.promise; + const late = info.update({sessionUpdate: "session_info_update", _meta: {late: true}}); + info.dispose(); + release.resolve(); + await Promise.all([snapshot, late]); + expect(sent).toEqual([{sessionUpdate: "session_info_update", title: "Current", _meta: null}]); + }); + + it("can resynchronize known metadata after an earlier write failed", async () => { + const send = vi.fn(async (_update: Update) => {}); + send.mockRejectedValueOnce(new Error("Write failed")); + const info = new AcpV2SessionInfo(send); + await expect(info.update({sessionUpdate: "session_info_update", _meta: {current: true}})).rejects.toThrow("Write failed"); + await info.snapshot("Current"); + expect(send.mock.calls.at(-1)?.[0]._meta).toEqual({current: true}); + }); + + it.each([1, 2])("clears only the resolved failure revision, preserving sibling metadata (published revision %s)", async revision => { + const sent: Update[] = []; + const client = {notify: vi.fn(async (_method, params) => { sent.push(params.update); }), request: vi.fn()} as unknown as AcpV2ClientConnection; + const v2 = new AcpV2Connection(client); + const failure: SessionFailure = {id: "incident", revision: 1, category: "service", severity: "warning", title: "Retrying", actions: ["retry"]}; + const state = createTestSessionState({sessionId: "session", sessionFailure: failure}); + const handler = new CodexEventHandler(v2.extensionOnlyV1View(), state, true, "epoch", state.subagents, undefined, false, false, false); + await v2.client.notify("session/update", {sessionId: "session", update: { + sessionUpdate: "session_info_update", + _meta: {jetbrains: {other: true, air: {version: 1, sibling: {value: null}, sessionFailure: {...failure, revision}}}}, + }}); + await handler.clearSessionFailure(); + await v2.snapshotSessionInfo("session", "Current"); + const air = (sent.at(-1)?._meta?.["jetbrains"] as {air: Record}).air; + expect(air["sibling"]).toEqual({value: null}); + expect(air["sessionFailure"]).toEqual(revision === 1 ? undefined : {...failure, revision}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json index 400bfb30..59af8267 100644 --- a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json @@ -202,6 +202,20 @@ "sessionId": "thread-1", "update": { "title": "Earlier session", + "_meta": null, + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "_meta": { + "codex": { + "threadStatus": { + "type": "idle" + } + } + }, "sessionUpdate": "session_info_update" } }, diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json index abe42a40..2788dd8f 100644 --- a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json @@ -314,6 +314,20 @@ "sessionId": "thread-1", "update": { "title": "Inserted via session/prompt", + "_meta": null, + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "_meta": { + "codex": { + "threadStatus": { + "type": "idle" + } + } + }, "sessionUpdate": "session_info_update" } }, diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json index 400bfb30..59af8267 100644 --- a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json @@ -202,6 +202,20 @@ "sessionId": "thread-1", "update": { "title": "Earlier session", + "_meta": null, + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "_meta": { + "codex": { + "threadStatus": { + "type": "idle" + } + } + }, "sessionUpdate": "session_info_update" } }, diff --git a/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts b/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts index 1271ed67..9254c26a 100644 --- a/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts +++ b/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts @@ -2,7 +2,8 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import * as acp from '@agentclientprotocol/sdk/unstable'; import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2/unstable'; import {createAcpAgentRouter} from '../../AcpAgentRouter'; -import {CodexAcpServer} from '../../CodexAcpServer'; +import {AcpV2Connection} from '../../ACPSessionConnection'; +import {CodexAcpServer, type SessionFailure} from '../../CodexAcpServer'; import {CodexAcpClient} from '../../CodexAcpClient'; import {CodexAppServerClient} from '../../CodexAppServerClient'; import type {SkillsListParams, SkillsListResponse, Thread} from '../../app-server/v2'; @@ -388,7 +389,7 @@ describe('Session lifecycle over ACP v2', () => { }); expect(updates.at(-1)?.update).toEqual({sessionUpdate: "state_update", state: "idle"}); // Commands are in the response; current info and foreground state are notified. - const sessionUpdates = await waitForSessionUpdates(updates, ["session_info_update", "state_update"]); + const sessionUpdates = await waitForSessionUpdates(updates, ["session_info_update", "session_info_update", "state_update"]); await expect(dump({response, codexRequests, updates: sessionUpdates})) .toMatchFileSnapshot(snapshot); @@ -405,9 +406,70 @@ describe('Session lifecycle over ACP v2', () => { expect(updates[0]?.update).toEqual({ sessionUpdate: "session_info_update", title, + _meta: null, }); }); + it.each([undefined, null, {type: "start" as const}])('resynchronizes metadata with replayFrom %s', async replayFrom => { + const {connection, agent, updates} = await connectV2Client(); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); + const failure: SessionFailure = {id: "current-incident", revision: 1, category: "service", + severity: "warning", title: "Retrying", actions: ["retry"]}; + agent().getSessionState(sessionId).sessionFailure = failure; + // @ts-expect-error - exercise the shared outbound path as well as direct v2 publication + const outbound = agent().connection as acp.AgentContext; + await outbound.notify(acp.methods.client.session.update, {sessionId, update: { + sessionUpdate: "session_info_update", _meta: {shared: {nested: null}, removed: true, ns: {old: true}}, + }, _meta: {sharedEvent: {literal: null}}}); + await vi.waitFor(() => expect(updates.at(-1)?._meta).toEqual({sharedEvent: {literal: null}})); + await AcpV2Connection.of(outbound)!.client.notify("session/update", {sessionId, update: { + sessionUpdate: "session_info_update", _meta: {direct: true, removed: null, ns: {literal: null}, + jetbrains: {air: {version: 1, sessionFailure: failure}}}, + }, _meta: {oneOff: {literal: null}}}); + await vi.waitFor(() => expect(updates.at(-1)?._meta).toEqual({oneOff: {literal: null}})); + + const start = updates.length; + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, cwd, ...(replayFrom === undefined ? {} : {replayFrom}), + }); + const info = updates.slice(start).filter(({update}) => update.sessionUpdate === "session_info_update"); + expect(info.some(({update}) => update._meta === null)).toBe(true); + let stored: Record = {unseenByAgent: true}; + for (const {update} of info) { + if (update._meta === null) stored = {}; + else if (update._meta && typeof update._meta === "object") { + for (const [key, value] of Object.entries(update._meta)) { + if (value === null) delete stored[key]; + else stored[key] = value; + } + } + } + // Both kinds of active resume retain current metadata despite state reinstallation. + expect(stored).toEqual({ + codex: {threadStatus: {type: "idle"}}, + shared: {nested: null}, direct: true, ns: {literal: null}, + jetbrains: {air: {version: 1, sessionFailure: failure}}, + }); + expect(agent().getSessionState(sessionId).sessionFailure).toEqual(failure); + }); + + it('does not restore metadata from a closed session lifetime', async () => { + const {connection, agent, updates} = await connectV2Client(); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); + // @ts-expect-error - publish metadata before closing this session lifetime + const outbound = agent().connection as acp.AgentContext; + await outbound.notify(acp.methods.client.session.update, {sessionId, update: { + sessionUpdate: "session_info_update", _meta: {oldLifetime: true}, + }}); + await connection.agent.request(acpV2.methods.agent.session.close, {sessionId}); + updates.splice(0); + await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); + const info = updates.filter(({update}) => update.sessionUpdate === "session_info_update"); + expect(info.map(({update}) => update._meta)).toEqual([null, {codex: {threadStatus: {type: "idle"}}}]); + }); + it('rejects resume with an unknown replayFrom type', async () => { const {connection, agent, codexRequests} = await connectV2Client(); closeClient = () => connection.close(); @@ -517,10 +579,11 @@ describe('Session lifecycle over ACP v2', () => { await resumed; await renamed; - const info = updates.filter(({update}) => update.sessionUpdate === "session_info_update"); + const info = updates.filter(({update}) => update.sessionUpdate === "session_info_update" && "title" in update); expect(info.at(-1)?.update).toEqual({ sessionUpdate: "session_info_update", title: "Renamed during replay", + _meta: null, }); const lastHistoryIndex = updates.map(({update}) => update.sessionUpdate).lastIndexOf("agent_message_chunk"); expect(updates.indexOf(info.at(-1)!)).toBeGreaterThan(lastHistoryIndex); @@ -531,7 +594,7 @@ describe('Session lifecycle over ACP v2', () => { closeClient = () => connection.close(); await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); - await waitForSessionUpdates(updates, ["session_info_update", "state_update"]); + await waitForSessionUpdates(updates, ["session_info_update", "session_info_update", "state_update"]); expect(updates.some(({update}) => update.sessionUpdate.endsWith("_message_chunk") || update.sessionUpdate.endsWith("_message") diff --git a/src/__tests__/CodexACPAgent/session-message-fence.test.ts b/src/__tests__/CodexACPAgent/session-message-fence.test.ts index 67437051..382dcfb4 100644 --- a/src/__tests__/CodexACPAgent/session-message-fence.test.ts +++ b/src/__tests__/CodexACPAgent/session-message-fence.test.ts @@ -76,6 +76,9 @@ describe("Post-close session delivery", () => { })).rejects.toMatchObject({code: -32800}); if (v2) { await v2.updateState(id, {state: "running"}); + await v2.snapshotSessionInfo(id, "Late metadata"); + await v2.client.notify("session/update", {sessionId: id, + update: {sessionUpdate: "session_info_update", _meta: {late: true}}}); await v2.replaySessionUpdate(id, {sessionUpdate: "agent_message_chunk", messageId: "late-replay", content: {type: "text", text: "Late replay"}}, new Set()); await v2.client.notify("session/update", {sessionId: id, update: {sessionUpdate: "state_update", state: "idle"}}); diff --git a/src/__tests__/integration/foreground-state-v2.test.ts b/src/__tests__/integration/foreground-state-v2.test.ts index acb3f37a..c6554edd 100644 --- a/src/__tests__/integration/foreground-state-v2.test.ts +++ b/src/__tests__/integration/foreground-state-v2.test.ts @@ -109,7 +109,7 @@ describeIntegration("integration: ACP v2 foreground state", () => { const replayedMessages = updates.some(({update}) => update.sessionUpdate === "user_message" || update.sessionUpdate === "user_message_chunk"); expect(replayedMessages).toBe(replayFrom != null); - const info = updates.filter(({update}) => update.sessionUpdate === "session_info_update").at(-1); + const info = updates.filter(({update}) => update.sessionUpdate === "session_info_update" && "title" in update).at(-1); expect(info?.update).toHaveProperty("title"); const lastMessage = updates.map(({update}) => update.sessionUpdate).lastIndexOf("agent_message_chunk"); expect(updates.indexOf(info!)).toBeGreaterThan(lastMessage);