From 30a966c99230579c7e4ec83f9f0a7d23de87c732 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 22 Sep 2026 14:11:11 +0200 Subject: [PATCH] feat: Add experimental session notices for Codex advisories --- readme-dev.md | 18 ++ src/CodexAcpServer.ts | 2 + src/CodexEventHandler.ts | 35 ++- src/SessionNotice.ts | 20 ++ .../data/session-notices-advisories.json | 82 +++++ .../data/session-notices-air-fallback.json | 134 ++++++++ .../session-notices-compaction-lifecycle.json | 26 ++ .../data/session-notices-defaults-wire.json | 64 ++++ .../data/session-notices-legacy-wire.json | 30 ++ .../data/session-notices-legacy.json | 75 +++++ .../data/session-notices-native-child.json | 82 +++++ .../session-notices-optional-content.json | 80 +++++ .../data/session-notices-repeated-wire.json | 50 +++ .../data/session-notices-replay.json | 54 ++++ .../data/session-notices-wire.json | 40 +++ .../session-notices-with-failure-wire.json | 68 ++++ .../CodexACPAgent/session-notices.test.ts | 295 ++++++++++++++++++ .../typed-session-failure-wire.test.ts | 215 ++++++++++++- 18 files changed, 1362 insertions(+), 8 deletions(-) create mode 100644 src/SessionNotice.ts create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-advisories.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-air-fallback.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-compaction-lifecycle.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-defaults-wire.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-legacy-wire.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-legacy.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-native-child.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-optional-content.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-repeated-wire.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-wire.json create mode 100644 src/__tests__/CodexACPAgent/data/session-notices-with-failure-wire.json create mode 100644 src/__tests__/CodexACPAgent/session-notices.test.ts diff --git a/readme-dev.md b/readme-dev.md index 135fb112..e690116f 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -85,6 +85,24 @@ npm run package:all 2. Regenerate Codex types in `src/app-server/`: `npm run generate-types` 3. Ensure there are no type errors or failed tests: `npm run typecheck` and `npm run test` +### Session notices + +The adapter implements [Session Notices](https://agentclientprotocol.com/rfds/session-notices) +for Codex warnings, configuration warnings, deprecation notices, model rerouting, and the legacy +`thread/compacted` advisory when the client advertises `clientCapabilities.session.notices: {}`. +These are live `session/update` notifications with +`sessionUpdate: "notice"`, a severity, a plain-text title, and optional description. +They are not replayed from session history and repeated notices remain independent events. + +Without that capability (including absent or null capability objects), the adapter preserves +the existing assistant/thought text or AIR `sessionFailure` advisory records. When notices are +enabled, they take precedence over AIR advisory records. Clients control their presentation; +the adapter does not rely on notices being displayed. + +Command replies, review results, and terminal/retrying errors retain their existing response or +failure channels. Clients advertising session compaction support continue to receive the dedicated +compaction lifecycle instead of the legacy completion advisory. + ### AIR diff statistics See the [diff statistics specification](docs/diff-statistics-extension.md) for the diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 24a97001..f2b40a41 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -111,6 +111,7 @@ import { type TerminalOutputMode, } from "./TerminalOutputMode"; import {clientSupportsPlanUpdates} from "./PlanCapabilities"; +import {clientSupportsNotices} from "./SessionNotice"; import { createAgentTextMessageChunk, createAgentTextThoughtChunk, @@ -2815,6 +2816,7 @@ export class CodexAcpServer { (accountUpdated) => this.handleAccountUpdated(accountUpdated), agentFileChangeReportRequest !== null, clientSupportsCompaction(this.clientCapabilities), + clientSupportsNotices(this.clientCapabilities), ); eventHandler = promptEventHandler; const permissionLifecycle = this.permissionLifecycleContext(sessionState); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index f4aa865c..1990adf9 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -84,6 +84,7 @@ import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import type {SubagentState} from "./subagents/AcpSubagents"; import {mergeRateLimitSnapshot} from "./RateLimitsMap"; import {AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES} from "./AgentFileChangeReport"; +import {createSessionNotice} from "./SessionNotice"; export { stripShellPrefix }; @@ -249,6 +250,7 @@ export class CodexEventHandler { onAccountUpdated?: (notification: AccountUpdatedNotification) => void, collectTurnDiffs = false, private readonly supportsCompaction = false, + private readonly supportsNotices = false, ) { this.onAccountUpdated = onAccountUpdated; this.sessionState = sessionState; @@ -710,6 +712,9 @@ export class CodexEventHandler { } private async createConfigWarningEvent(event: ConfigWarningNotification): Promise { + if (this.supportsNotices) { + return createSessionNotice("warning", event.summary.trim() || "Configuration warning", event.details); + } if (this.supportsTypedSessionFailures) { return this.createSessionFailureUpdate(this.recordSessionNotice(...this.sessionNoticeContent(event.summary, event.details))); } @@ -717,12 +722,11 @@ export class CodexEventHandler { return createAgentTextMessageChunk(`Config warning: ${text}\n\n`); } - /** - * Unlike `warning` and `configWarning`, this notification was dropped outright, so there is no - * legacy rendering to preserve. It is surfaced only to clients that negotiated typed records; - * every other client keeps seeing exactly what it sees today, which is nothing. - */ private createDeprecationNoticeEvent(event: DeprecationNoticeNotification): UpdateSessionEvent | null { + if (this.supportsNotices) { + return createSessionNotice("warning", event.summary.trim() || "Deprecated configuration", event.details); + } + // Legacy clients without typed failures have never received deprecation notices. if (!this.supportsTypedSessionFailures) return null; return this.createSessionFailureUpdate( this.recordSessionNotice(...this.sessionNoticeContent(event.summary, event.details)), @@ -730,6 +734,9 @@ export class CodexEventHandler { } private createWarningEvent(event: WarningNotification): UpdateSessionEvent { + if (this.supportsNotices) { + return createSessionNotice("warning", event.message.trim() || "Codex warning"); + } if (this.supportsTypedSessionFailures) { return this.createSessionFailureUpdate(this.recordSessionNotice(event.message)); } @@ -737,7 +744,14 @@ export class CodexEventHandler { } private createModelReroutedEvent(event: ModelReroutedNotification): UpdateSessionEvent { - return createAgentTextThoughtChunk(`Model rerouted from ${event.fromModel} to ${event.toModel} (${event.reason}).\n\n`); + if (!this.supportsNotices) { + return createAgentTextThoughtChunk(`Model rerouted from ${event.fromModel} to ${event.toModel} (${event.reason}).\n\n`); + } + return createSessionNotice( + "info", + "Model rerouted", + `Switched from ${event.fromModel} to ${event.toModel} (${event.reason}).`, + ); } private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null { @@ -1018,7 +1032,14 @@ export class CodexEventHandler { } private createContextCompactedEvent(): UpdateSessionEvent { - return createAgentTextMessageChunk("*Context compacted to fit the model's context window.*\n\n"); + if (!this.supportsNotices) { + return createAgentTextMessageChunk("*Context compacted to fit the model's context window.*\n\n"); + } + return createSessionNotice( + "info", + "Context compacted", + "Conversation compacted to fit the model's context window.", + ); } private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { diff --git a/src/SessionNotice.ts b/src/SessionNotice.ts new file mode 100644 index 00000000..789bd561 --- /dev/null +++ b/src/SessionNotice.ts @@ -0,0 +1,20 @@ +import type {ClientCapabilities, Notice, NoticeSeverity} from "@agentclientprotocol/sdk"; + +export function clientSupportsNotices(capabilities?: ClientCapabilities | null): boolean { + const notices = capabilities?.session?.notices; + return typeof notices === "object" && notices !== null && !Array.isArray(notices); +} + +/** Live advisory only: no identity, lifecycle, or replay. Callers must negotiate support. */ +export function createSessionNotice( + severity: NoticeSeverity, + title: string, + description?: string | null, +): Notice & {sessionUpdate: "notice"} { + return { + sessionUpdate: "notice", + severity, + title, + ...(description == null ? {} : {description}), + }; +} diff --git a/src/__tests__/CodexACPAgent/data/session-notices-advisories.json b/src/__tests__/CodexACPAgent/data/session-notices-advisories.json new file mode 100644 index 00000000..38c8b809 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-advisories.json @@ -0,0 +1,82 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Optional integration unavailable" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Optional integration unavailable" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Configuration fallback", + "description": "Using the default configuration.\nCheck the configured path." + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Deprecated setting", + "description": "Use the replacement setting." + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "info", + "title": "Model rerouted", + "description": "Switched from original-model to fallback-model (highRiskCyberActivity)." + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "info", + "title": "Context compacted", + "description": "Conversation compacted to fit the model's context window." + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-air-fallback.json b/src/__tests__/CodexACPAgent/data/session-notices-air-fallback.json new file mode 100644 index 00000000..5071a107 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-air-fallback.json @@ -0,0 +1,134 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "id", + "revision": 1, + "category": "unknown", + "severity": "warning", + "title": "Optional integration unavailable", + "actions": [] + } + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "id", + "revision": 2, + "category": "unknown", + "severity": "warning", + "title": "Optional integration unavailable", + "actions": [] + } + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "id", + "revision": 1, + "category": "unknown", + "severity": "warning", + "title": " Configuration fallback — Using the default configuration.\nCheck the configured path.", + "actions": [] + } + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "id", + "revision": 1, + "category": "unknown", + "severity": "warning", + "title": " Deprecated setting — Use the replacement setting.", + "actions": [] + } + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "Model rerouted from original-model to fallback-model (highRiskCyberActivity).\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "*Context compacted to fit the model's context window.*\n\n" + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-compaction-lifecycle.json b/src/__tests__/CodexACPAgent/data/session-notices-compaction-lifecycle.json new file mode 100644 index 00000000..6e0ece19 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-compaction-lifecycle.json @@ -0,0 +1,26 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "compaction_update", + "compactionId": "notice-compaction", + "status": "in_progress" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "compaction_update", + "compactionId": "notice-compaction", + "status": "completed" + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-defaults-wire.json b/src/__tests__/CodexACPAgent/data/session-notices-defaults-wire.json new file mode 100644 index 00000000..99648691 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-defaults-wire.json @@ -0,0 +1,64 @@ +[ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notice-defaults", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Codex warning" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notice-defaults", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Configuration warning" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notice-defaults", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Deprecated configuration" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notice-defaults", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Configuration details", + "description": "" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notice-defaults", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Deprecated option", + "description": "" + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-notices-legacy-wire.json b/src/__tests__/CodexACPAgent/data/session-notices-legacy-wire.json new file mode 100644 index 00000000..f259833a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-legacy-wire.json @@ -0,0 +1,30 @@ +[ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-legacy-notices", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Warning: Legacy warning\n\n" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-legacy-notices", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Config warning: Legacy configuration warning\n\nConfiguration details\n\n" + } + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-notices-legacy.json b/src/__tests__/CodexACPAgent/data/session-notices-legacy.json new file mode 100644 index 00000000..b1c31f0c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-legacy.json @@ -0,0 +1,75 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Warning: Optional integration unavailable\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Warning: Optional integration unavailable\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Config warning: Configuration fallback \n\nUsing the default configuration.\nCheck the configured path.\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "Model rerouted from original-model to fallback-model (highRiskCyberActivity).\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "*Context compacted to fit the model's context window.*\n\n" + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-native-child.json b/src/__tests__/CodexACPAgent/data/session-notices-native-child.json new file mode 100644 index 00000000..ac9767a4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-native-child.json @@ -0,0 +1,82 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "notice-child", + "name": "Notice child", + "task": "Check the delegated task.", + "capabilities": {} + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-child", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Child integration unavailable" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-child", + "update": { + "sessionUpdate": "notice", + "severity": "info", + "title": "Model rerouted", + "description": "Switched from original-model to fallback-model (highRiskCyberActivity)." + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-child", + "update": { + "sessionUpdate": "notice", + "severity": "info", + "title": "Context compacted", + "description": "Conversation compacted to fit the model's context window." + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Root integration unavailable" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "notice-child", + "state": "completed" + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-optional-content.json b/src/__tests__/CodexACPAgent/data/session-notices-optional-content.json new file mode 100644 index 00000000..0c807fe2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-optional-content.json @@ -0,0 +1,80 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Codex warning" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Configuration warning" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Deprecated configuration" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Default configuration", + "description": "" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Old configuration", + "description": "" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Global warning" + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-repeated-wire.json b/src/__tests__/CodexACPAgent/data/session-notices-repeated-wire.json new file mode 100644 index 00000000..ea02c4dd --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-repeated-wire.json @@ -0,0 +1,50 @@ +[ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-repeated-notice", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Repeated warning" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-repeated-notice", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Repeated warning" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-repeated-notice", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Different warning" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-repeated-notice", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Repeated warning" + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-notices-replay.json b/src/__tests__/CodexACPAgent/data/session-notices-replay.json new file mode 100644 index 00000000..784b649f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-replay.json @@ -0,0 +1,54 @@ +[ + { + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "before-compaction", + "content": { + "type": "text", + "text": "Before compaction." + } + } + } + ] + }, + { + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "history-compaction", + "kind": "think", + "title": "Compact conversation", + "status": "completed", + "_meta": { + "contextCompaction": { + "version": 1 + } + } + } + } + ] + }, + { + "method": "sessionUpdate", + "args": [ + { + "sessionId": "notice-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "after-compaction", + "content": { + "type": "text", + "text": "After compaction." + } + } + } + ] + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-notices-wire.json b/src/__tests__/CodexACPAgent/data/session-notices-wire.json new file mode 100644 index 00000000..31af1acf --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-wire.json @@ -0,0 +1,40 @@ +[ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notices", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Heads up: Long threads and multiple compactions can cause the model to be less accurate." + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notices", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Unknown key `foo`", + "description": "in ~/.codex/config.toml" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-notices", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "`--legacy-flag` is deprecated", + "description": "Use `--flag` instead." + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-notices-with-failure-wire.json b/src/__tests__/CodexACPAgent/data/session-notices-with-failure-wire.json new file mode 100644 index 00000000..f1cec313 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-notices-with-failure-wire.json @@ -0,0 +1,68 @@ +[ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-mixed", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "turn-id:error", + "revision": 1, + "category": "service", + "severity": "error", + "title": "provider blew up", + "actions": [ + "retry" + ] + } + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-mixed", + "update": { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Unrelated advisory" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "wire-mixed", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "turn-id:error", + "revision": 2, + "category": "service", + "severity": "error", + "title": "provider blew up", + "actions": [ + "retry" + ] + } + } + } + } + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/session-notices.test.ts b/src/__tests__/CodexACPAgent/session-notices.test.ts new file mode 100644 index 00000000..27a9046a --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-notices.test.ts @@ -0,0 +1,295 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import {describe, expect, it, vi} from "vitest"; +import {ACPSessionConnection} from "../../ACPSessionConnection"; +import type {ServerNotification} from "../../app-server"; +import type {Thread, Turn} from "../../app-server/v2"; +import {CodexSubagentEventRouter} from "../../subagents/CodexSubagentEventRouter"; +import { + createCodexMockTestFixture, + createTestModel, + createTestSessionState, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +const sessionId = "notice-session"; +const turnId = "notice-turn"; +const childSessionId = "notice-child"; +const noticeCapabilities: acp.ClientCapabilities = {session: {notices: {}}}; +const airCapabilities = {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}}; + +describe("session notices", () => { + it.each([ + ["empty notice capability", noticeCapabilities], + ["extended notice capability", {session: {notices: {_meta: {future: true}}}}], + ["AIR typed failures", {...noticeCapabilities, ...airCapabilities}], + ])("delivers independent advisory notices with %s", async (_label, capabilities) => { + const fixture = await createFixture(capabilities); + await sendNotifications(fixture, advisoryNotifications()); + + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-advisories.json"); + }); + + it.each([ + ["omitted capabilities", undefined], + ["null capabilities", null], + ["empty capabilities", {}], + ["null session capabilities", {session: null}], + ["empty session capabilities", {session: {}}], + ["array session capabilities", {session: []}], + ["boolean session capabilities", {session: true}], + ["null notices", {session: {notices: null}}], + ["array notices", {session: {notices: []}}], + ["true notices", {session: {notices: true}}], + ["false notices", {session: {notices: false}}], + ["string notices", {session: {notices: "supported"}}], + ["numeric notices", {session: {notices: 1}}], + ])("preserves legacy output with %s", async (_label, capabilities) => { + const fixture = await createFixture(capabilities); + await sendNotifications(fixture, advisoryNotifications()); + + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-legacy.json"); + }); + + it.each([ + ["missing session capabilities", airCapabilities], + ["null session capabilities", {...airCapabilities, session: null}], + ["empty session capabilities", {...airCapabilities, session: {}}], + ["null notices", {...airCapabilities, session: {notices: null}}], + ["invalid notices", {...airCapabilities, session: {notices: true}}], + ])("preserves AIR advisory output with %s", async (_label, capabilities) => { + const fixture = await createFixture(capabilities); + await sendNotifications(fixture, advisoryNotifications()); + + await expect(fixture.getAcpConnectionDump(["args.0.update._meta.jetbrains.air.sessionFailure.id"])) + .toMatchFileSnapshot("data/session-notices-air-fallback.json"); + }); + + it("preserves optional descriptions and supplies nonempty titles for empty upstream text", async () => { + const fixture = await createFixture(noticeCapabilities); + await sendNotifications(fixture, [ + {method: "warning", params: {threadId: sessionId, message: " \n "}}, + {method: "configWarning", params: {summary: " \t ", details: null}}, + {method: "deprecationNotice", params: {summary: "", details: null}}, + {method: "configWarning", params: {summary: "Default configuration", details: ""}}, + {method: "deprecationNotice", params: {summary: "Old configuration", details: ""}}, + {method: "warning", params: {threadId: null, message: " Global warning "}}, + ]); + + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-optional-content.json"); + }); + + it("uses the negotiated compaction lifecycle without adding a redundant notice", async () => { + const fixture = await createFixture({session: {notices: {}, compaction: {}}}); + const item = {type: "contextCompaction" as const, id: "notice-compaction"}; + await sendNotifications(fixture, [ + {method: "item/started", params: {threadId: sessionId, turnId, startedAtMs: 0, item}}, + {method: "item/completed", params: {threadId: sessionId, turnId, completedAtMs: 1, item}}, + {method: "thread/compacted", params: {threadId: sessionId, turnId}}, + ]); + + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-compaction-lifecycle.json"); + }); + + it("buffers child notices until the child session exists and stops them when it closes", async () => { + const fixture = await createFixture({ + ...noticeCapabilities, + _meta: {jetbrains: {air: {version: 1, capabilities: ["nativeSubagentSessions"]}}}, + }, true); + await sendNotifications(fixture, [{ + method: "item/started", + params: { + threadId: sessionId, + turnId, + startedAtMs: 0, + item: { + type: "collabAgentToolCall", + id: "spawn-notice-child", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: sessionId, + receiverThreadIds: [childSessionId], + prompt: "Check the delegated task.", + model: null, + reasoningEffort: null, + agentsStates: {[childSessionId]: {status: "running", message: null}}, + }, + }, + }, { + method: "warning", + params: {threadId: childSessionId, message: "Child integration unavailable"}, + }, modelRerouted(childSessionId)]); + expect(fixture.getAcpConnectionEvents([])).toEqual([]); + + await sendNotifications(fixture, [{ + method: "item/started", + params: { + threadId: sessionId, + turnId, + startedAtMs: 0, + item: { + type: "subAgentActivity", + id: "notice-child-activity", + kind: "started", + agentThreadId: childSessionId, + agentPath: "/root/notice_child", + }, + }, + }, { + method: "thread/compacted", + params: {threadId: childSessionId, turnId: "child-turn"}, + }, { + method: "warning", + params: {threadId: sessionId, message: "Root integration unavailable"}, + }, { + method: "turn/completed", + params: {threadId: childSessionId, turn: {...createTurn("completed"), id: "child-turn"}}, + }, { + method: "warning", + params: {threadId: childSessionId, message: "Late child warning"}, + }]); + + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-native-child.json"); + }); + + it("keeps live notices out of loaded session history", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + const model = createTestModel(); + vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(appServer, "listModels").mockResolvedValue({data: [model], nextCursor: null}); + const thread = createThread(); + vi.spyOn(appServer, "threadResume").mockResolvedValue({ + thread, + model: model.id, + modelProvider: "openai", + serviceTier: null, + cwd: thread.cwd, + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, + turnsBackwardsCursor: null, + itemsBackwardsCursor: null, + }); + vi.spyOn(appServer, "threadReadWithHistory").mockResolvedValue({thread}); + mockCompletedPrompt(fixture); + await agent.initialize({protocolVersion: 1, clientCapabilities: noticeCapabilities}); + const request = {sessionId, cwd: thread.cwd, mcpServers: []}; + await agent.loadSession(request); + await agent.prompt({sessionId, prompt: [{type: "text", text: "Continue."}]}); + fixture.clearAcpConnectionDump(); + await sendNotifications(fixture, advisoryNotifications()); + expect(fixture.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate" + && event.args[0].update.sessionUpdate === "notice")).toHaveLength(6); + + fixture.clearAcpConnectionDump(); + await agent.loadSession(request); + const timeline = fixture.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate" + && ["agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update", "notice"] + .includes(event.args[0].update.sessionUpdate)); + await expect(JSON.stringify(timeline, null, 2)).toMatchFileSnapshot("data/session-notices-replay.json"); + + await agent.prompt({sessionId, prompt: [{type: "text", text: "Continue after loading."}]}); + fixture.clearAcpConnectionDump(); + await sendNotifications(fixture, advisoryNotifications()); + await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/session-notices-advisories.json"); + }); +}); + +async function createFixture(clientCapabilities?: unknown, nativeSubagents = false) { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + mockCompletedPrompt(fixture); + const sessionState = createTestSessionState({sessionId}); + if (nativeSubagents) { + sessionState.subagents = new CodexSubagentEventRouter( + sessionId, + true, + new ACPSessionConnection(fixture.getAcpConnection(), sessionId), + ); + } + vi.spyOn(agent, "getSessionState").mockReturnValue(sessionState); + // Exercise malformed capability values at the agent boundary as well as valid SDK inputs. + await agent.initialize({ + protocolVersion: 1, + ...(clientCapabilities === undefined ? {} : {clientCapabilities: clientCapabilities as acp.ClientCapabilities}), + }); + await agent.prompt({sessionId, prompt: [{type: "text", text: "Continue."}]}); + fixture.clearAcpConnectionDump(); + return fixture; +} + +function mockCompletedPrompt(fixture: CodexMockTestFixture): void { + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "turnStart").mockResolvedValue({turn: createTurn("inProgress")}); + vi.spyOn(appServer, "awaitTurnCompleted").mockResolvedValue({threadId: sessionId, turn: createTurn("completed")}); +} + +async function sendNotifications(fixture: CodexMockTestFixture, notifications: ServerNotification[]) { + for (const notification of notifications) fixture.sendServerNotification(notification); + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); +} + +function advisoryNotifications(): ServerNotification[] { + return [ + {method: "warning", params: {threadId: sessionId, message: "Optional integration unavailable"}}, + {method: "warning", params: {threadId: sessionId, message: "Optional integration unavailable"}}, + {method: "configWarning", params: {summary: " Configuration fallback ", details: "Using the default configuration.\nCheck the configured path."}}, + {method: "deprecationNotice", params: {summary: " Deprecated setting ", details: "Use the replacement setting."}}, + modelRerouted(sessionId), + {method: "thread/compacted", params: {threadId: sessionId, turnId}}, + ]; +} + +function modelRerouted(threadId: string): ServerNotification { + return { + method: "model/rerouted", + params: {threadId, turnId, fromModel: "original-model", toModel: "fallback-model", reason: "highRiskCyberActivity"}, + }; +} + +function createTurn(status: Turn["status"]): Turn { + return {id: turnId, items: [], itemsView: "full", status, error: null, startedAt: null, completedAt: null, durationMs: null}; +} + +function createThread(): Thread { + return { + id: sessionId, + sessionId, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "Notice history", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 1, + updatedAt: 2, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd: "/test/cwd", + cliVersion: "0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [{...createTurn("completed"), items: [ + {type: "agentMessage", id: "before-compaction", text: "Before compaction.", phase: null, memoryCitation: null, delivery: null, questions: null}, + {type: "contextCompaction", id: "history-compaction"}, + {type: "agentMessage", id: "after-compaction", text: "After compaction.", phase: null, memoryCitation: null, delivery: null, questions: null}, + ]}], + }; +} diff --git a/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts b/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts index 115ed58d..efe33dbe 100644 --- a/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts +++ b/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts @@ -6,6 +6,10 @@ import {CodexAcpServer} from "../../CodexAcpServer"; import {createTestSessionState} from "../acp-test-utils"; import {createMockConnections} from "./test-utils"; +const noticeCapabilities: acp.ClientCapabilities = { + session: {notices: {}}, +}; + const typedFailureCapabilities: acp.ClientCapabilities = { _meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}, }; @@ -412,6 +416,180 @@ describe("typed session failures over ACP transport", () => { }); }); +describe("standard session notices over ACP transport", () => { + it.each([ + ["without AIR session failures", noticeCapabilities], + ["with AIR session failures", {...typedFailureCapabilities, ...noticeCapabilities}], + ])("serializes standard notices %s", async (_name, clientCapabilities) => { + const fixture = await createNegotiatedNoticeFixture("wire-notices", clientCapabilities); + + for (const notification of [ + { + method: "warning", + params: { + threadId: fixture.sessionId, + message: "Heads up: Long threads and multiple compactions can cause the model to be less accurate.", + }, + }, + { + method: "configWarning", + params: {summary: "Unknown key `foo`", details: "in ~/.codex/config.toml"}, + }, + { + method: "deprecationNotice", + params: {summary: "`--legacy-flag` is deprecated", details: "Use `--flag` instead."}, + }, + ]) { + fixture.sendServerNotification(notification); + } + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(3)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + + await expect(`${JSON.stringify(fixture.wireUpdates, null, 2)}\n`).toMatchFileSnapshot( + "data/session-notices-wire.json", + ); + }); + + it("preserves absent and empty descriptions and supplies nonempty titles", async () => { + const fixture = await createNegotiatedNoticeFixture("wire-notice-defaults"); + + for (const notification of [ + {method: "warning", params: {threadId: fixture.sessionId, message: ""}}, + {method: "configWarning", params: {summary: "", details: null}}, + {method: "deprecationNotice", params: {summary: "", details: null}}, + {method: "configWarning", params: {summary: "Configuration details", details: ""}}, + {method: "deprecationNotice", params: {summary: "Deprecated option", details: ""}}, + ]) { + fixture.sendServerNotification(notification); + } + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(5)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + + await expect(`${JSON.stringify(fixture.wireUpdates, null, 2)}\n`).toMatchFileSnapshot( + "data/session-notices-defaults-wire.json", + ); + }); + + it("emits each repeated warning as an independent notice", async () => { + const fixture = await createNegotiatedNoticeFixture("wire-repeated-notice"); + + for (const message of ["Repeated warning", "Repeated warning", "Different warning", "Repeated warning"]) { + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message}, + }); + } + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(4)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + + await expect(`${JSON.stringify(fixture.wireUpdates, null, 2)}\n`).toMatchFileSnapshot( + "data/session-notices-repeated-wire.json", + ); + }); + + it("keeps notices separate from the revision sequence of a typed failure", async () => { + const fixture = await createNegotiatedNoticeFixture("wire-mixed", { + ...typedFailureCapabilities, + ...noticeCapabilities, + }); + const failure = { + method: "error", + params: { + threadId: fixture.sessionId, + turnId: "turn-id", + willRetry: false, + error: {message: "provider blew up", codexErrorInfo: "serverOverloaded", additionalDetails: null}, + }, + }; + + fixture.sendServerNotification(failure); + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message: "Unrelated advisory"}, + }); + fixture.sendServerNotification(failure); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(3)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + + await expect(`${JSON.stringify(fixture.wireUpdates, null, 2)}\n`).toMatchFileSnapshot( + "data/session-notices-with-failure-wire.json", + ); + }); + + it.each<{name: string; capabilities?: unknown}>([ + {name: "omitted capabilities"}, + {name: "empty capabilities", capabilities: {}}, + {name: "null session capabilities", capabilities: {session: null}}, + {name: "empty session capabilities", capabilities: {session: {}}}, + {name: "null notice capability", capabilities: {session: {notices: null}}}, + {name: "array notice capability", capabilities: {session: {notices: []}}}, + {name: "true notice capability", capabilities: {session: {notices: true}}}, + {name: "false notice capability", capabilities: {session: {notices: false}}}, + {name: "string notice capability", capabilities: {session: {notices: "supported"}}}, + {name: "numeric notice capability", capabilities: {session: {notices: 1}}}, + ])("retains legacy behavior without notice support: $name", async ({capabilities}) => { + const wireFixture = createWireFixture(); + const initialize = vi.spyOn(wireFixture.server, "initialize"); + await wireFixture.client.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + // Intentionally send malformed values through the real SDK capability parser. + ...(capabilities === undefined ? {} : {clientCapabilities: capabilities as acp.ClientCapabilities}), + }); + expect(initialize.mock.calls[0]![0].clientCapabilities?.session?.notices ?? null).toBeNull(); + const fixture = await settleSession(wireFixture, "wire-legacy-notices"); + + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message: "Legacy warning"}, + }); + fixture.sendServerNotification({ + method: "configWarning", + params: {summary: "Legacy configuration warning", details: "Configuration details"}, + }); + fixture.sendServerNotification({ + method: "deprecationNotice", + params: {summary: "Legacy deprecation", details: "Deprecated configuration details"}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(2)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + + await expect(`${JSON.stringify(fixture.wireUpdates, null, 2)}\n`).toMatchFileSnapshot( + "data/session-notices-legacy-wire.json", + ); + }); + + it("delivers notices to the SDK client and allows it to continue prompting", async () => { + const fixture = await createNegotiatedNoticeFixture("wire-notice-client"); + + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message: "A warning before the next prompt"}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + expect(fixture.updates).toEqual(fixture.wireUpdates.map(({params}) => params)); + expect(fixture.updates[0]!.update).toEqual({ + sessionUpdate: "notice", + severity: "warning", + title: "A warning before the next prompt", + }); + + const response = await fixture.client.prompt({ + sessionId: fixture.sessionId, + prompt: [{type: "text", text: "continue after the notice"}], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(fixture.updates).toHaveLength(1); + expect(fixture.appServer.turnStart).toHaveBeenCalledTimes(2); + }); +}); + /** A fixture whose session already completed a turn, so notifications route to a live event handler. */ async function createIdleFixture( sessionId: string, @@ -419,6 +597,21 @@ async function createIdleFixture( ) { const fixture = createWireFixture(); await fixture.initialize(clientCapabilities); + return settleSession(fixture, sessionId); +} + +async function createNegotiatedNoticeFixture( + sessionId: string, + clientCapabilities: acp.ClientCapabilities = noticeCapabilities, +) { + const fixture = createWireFixture(); + const initialize = vi.spyOn(fixture.server, "initialize"); + await fixture.initialize(clientCapabilities); + expect(initialize.mock.calls[0]![0].clientCapabilities?.session?.notices).toEqual({}); + return settleSession(fixture, sessionId); +} + +async function settleSession(fixture: ReturnType, sessionId: string) { const sessionState = createTestSessionState({sessionId, account: {type: "apiKey"}}); vi.spyOn(fixture.server, "getSessionState").mockReturnValue(sessionState); vi.spyOn(fixture.appServer, "turnStart").mockResolvedValue({turn: createTurn("inProgress")}); @@ -432,6 +625,7 @@ async function createIdleFixture( prompt: [{type: "text", text: "settle the session"}], }); fixture.updates.splice(0); + fixture.wireUpdates.splice(0); return {...fixture, sessionId}; } @@ -443,7 +637,25 @@ function createWireFixture(options: {exitCode?: number | null; stderr?: string} vi.spyOn(appServer, "initialize").mockResolvedValue({codexHome: null} as never); const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); + const wireUpdates: Array<{ + jsonrpc: "2.0"; + method: "session/update"; + params: {sessionId: string; update: Record}; + }> = []; + const decoder = new TextDecoder(); + let pending = ""; + const agentToClient = new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, {stream: true}); + let newline: number; + while ((newline = pending.indexOf("\n")) !== -1) { + const message = JSON.parse(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + if (message.method === "session/update") wireUpdates.push(message); + } + controller.enqueue(chunk); + }, + }); const updates: acp.SessionNotification[] = []; let server!: CodexAcpServer; const client = new acp.ClientSideConnection( @@ -474,6 +686,7 @@ function createWireFixture(options: {exitCode?: number | null; stderr?: string} codexClient, appServer, updates, + wireUpdates, get server(): CodexAcpServer { return server; },