Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/diff-statistics-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# AIR diff statistics extension

Status: Experimental

Agents can attach added and removed line counts to an ACP `diff` content block.
Clients use these values without comparing the block's texts again.
The extension applies to any ACP agent, including Codex.

## Wire format

The payload belongs to the individual diff block at `_meta.jetbrains.air.diffStats`.

```json
{
"type": "diff",
"path": "/project/file.txt",
"oldText": "old\n",
"newText": "new\nextra\n",
"_meta": {
"kind": "update",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 2,
"removed": 1
}
}
}
}
}
```

`jetbrains.air.version` identifies the AIR envelope. Clients accept integer versions of at least 1.
`diffStats.version` identifies this payload. This specification defines version 1 only.
Agents preserve other metadata, including `kind`.

| Field | Type | Meaning |
| --- | --- | --- |
| `version` | integer | Must equal `1`. |
| `added` | integer | Number of added lines, between 0 and 2147483647. |
| `removed` | integer | Number of removed lines, between 0 and 2147483647. |

All three fields are required. Numeric strings are invalid.
Statistics contain no navigation coordinates. Clients must not compare texts to obtain coordinates when they receive valid counts.

## Count semantics

For updates, counts describe the addition and deletion operations in the supplied patch.
Context lines and `No newline at end of file` markers do not contribute to counts.
A replacement contributes both added and removed lines.
A patch can contain operations that leave the normalized file content unchanged.
Clients preserve the patch counts instead of recomputing a minimal diff.
Relocating an exact hunk does not change its counts.

For creation and deletion, count the supplied file content.
Treat CRLF and CR as line boundaries and do not count an extra line after the final terminator.
An empty string has zero lines. One line terminator represents one empty line.
Creation has zero removed lines; deletion has zero added lines.

Each diff block owns its statistics.
A text revision carries statistics for that revision, or omits the payload.
Clients invalidate old statistics when the texts change.
Status-only updates preserve previous statistics.
Late statistics may replace calculated values for unchanged texts.

## Availability and compatibility

This is optional display metadata. No capability negotiation is required.
Clients that do not understand it can ignore it and render the standard diff content.
Agents still send the usual `path`, `oldText`, and `newText` values.

An agent omits statistics when it cannot produce valid counts.
Clients use their normal comparison when metadata is missing, malformed, or unsupported.
Unknown fields do not invalidate a valid payload.

The earlier experimental `com.intellij/diffStats` key is not part of this contract.
AIR ignores that key and uses its normal fallback.
Existing persisted statistics, including stored navigation lines, remain readable without migration.

## Codex behavior

The existing patch application validates the file change and produces the texts for ACP.
The statistics calculator then reads only the parsed patch. It receives no file texts.
It validates hunk sizes and coordinates and counts `+` and `-` operations.
It does not verify file contents again or locate a navigation line.

Tests: `src/__tests__/DiffStats.test.ts` and
`src/__tests__/CodexACPAgent/file-change-events.test.ts`.
5 changes: 5 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,8 @@ npm run package:all
1. Update the `@openai/codex` version in `package.json` (under `dependencies`).
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`

### AIR diff statistics

See the [diff statistics specification](docs/diff-statistics-extension.md) for the
`_meta.jetbrains.air.diffStats` payload and its compatibility rules.
1 change: 1 addition & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const JETBRAINS_META_KEY = "jetbrains";
export const AIR_META_KEY = "air";
export const AIR_EXTENSION_VERSION_KEY = "version";
export const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
export const AIR_DIFF_STATS_KEY = "diffStats";
export const AIR_SESSION_FAILURE_KEY = "sessionFailure";
export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport";
export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions";
Expand Down
50 changes: 19 additions & 31 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ContentBlock, ToolCallContent } from "@agentclientprotocol/sdk";
import { applyPatch, parsePatch, reversePatch } from "diff";
import { applyPatch, parsePatch, reversePatch, type StructuredPatch } from "diff";
import { DiffStatsCalculator } from "./DiffStats";
import { AIR_DIFF_STATS_KEY, withAirMeta } from "./AirExtension";
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { UpdateSessionEvent } from "./ACPSessionConnection";
Expand Down Expand Up @@ -47,6 +49,7 @@ type ContextCompactionItem = ThreadItem & { type: "contextCompaction" };
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;

const CONTEXT_COMPACTION_META = createContextCompactionMeta();
const DIFF_STATS = new DiffStatsCalculator();

function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
switch (status) {
Expand Down Expand Up @@ -838,65 +841,52 @@ async function createAddFileContent(change: FileUpdateChange): Promise<ToolCallC
oldText: null,
newText: change.diff, // app-server always returns file content instead of diff
path: change.path,
_meta: {
kind: "add",
},
_meta: withAirMeta({ kind: "add" }, AIR_DIFF_STATS_KEY, DIFF_STATS.addedFile(change.diff)),
};
}

async function createUpdateFileContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
if (change.kind.type !== "update") return null;

const unifiedDiff = recoverCorruptedDiff(change.diff);
const patches = parsePatch(unifiedDiff);
if (patches.length !== 1) return null;
const patch = patches[0]!;
const movePath = change.kind.move_path;

const oldContent = await readFileContent(change.path);
if (oldContent !== null) {
const patchedContent = applyPatch(oldContent, unifiedDiff);
const patchedContent = applyPatch(oldContent, patch);
if (patchedContent === false) {
// If Codex runs in full access mode, the file might already be patched.
// we can verify this by checking if the reverted patch applies.
const revertedPatch = revertPatch(unifiedDiff);
if (revertedPatch) {
const revertedContent = applyPatch(oldContent, revertedPatch);
if (revertedContent !== false) {
return createUpdateDiffContent(change.path, revertedContent, oldContent);
}
const revertedContent = applyPatch(oldContent, reversePatch(patch));
if (revertedContent !== false) {
return createUpdateDiffContent(change.path, revertedContent, oldContent, patch);
}
return null;
}
return createUpdateDiffContent(movePath ?? change.path, oldContent, patchedContent);
return createUpdateDiffContent(movePath ?? change.path, oldContent, patchedContent, patch);
}

if (!movePath) return null;
const newContent = await readFileContent(movePath);
if (newContent === null) return null;

const revertedPatch = revertPatch(unifiedDiff);
if (!revertedPatch) return null;

const revertedContent = applyPatch(newContent, revertedPatch);
const revertedContent = applyPatch(newContent, reversePatch(patch));
if (revertedContent === false) return null;

return createUpdateDiffContent(movePath, revertedContent, newContent);
}

function revertPatch(unifiedDiff: string) {
const [patch] = parsePatch(unifiedDiff);
if (!patch) return null;

return reversePatch(patch);
return createUpdateDiffContent(movePath, revertedContent, newContent, patch);
}

function createUpdateDiffContent(path: string, oldText: string, newText: string): ToolCallContent {
function createUpdateDiffContent(path: string, oldText: string, newText: string, patch: StructuredPatch): ToolCallContent {
const stats = DIFF_STATS.update(patch);
return {
type: "diff",
oldText,
newText,
path,
_meta: {
kind: "update",
},
_meta: stats ? withAirMeta({ kind: "update" }, AIR_DIFF_STATS_KEY, stats) : { kind: "update" },
};
}

Expand All @@ -906,9 +896,7 @@ async function createDeleteFileContent(change: FileUpdateChange): Promise<ToolCa
oldText: change.diff, // app-server always returns file content instead of diff
newText: "",
path: change.path,
_meta: {
kind: "delete",
}
_meta: withAirMeta({ kind: "delete" }, AIR_DIFF_STATS_KEY, DIFF_STATS.deletedFile(change.diff))
}
}

Expand Down
74 changes: 74 additions & 0 deletions src/DiffStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { StructuredPatch } from "diff";

export type DiffStats = {
version: 1;
added: number;
removed: number;
};

export class DiffStatsCalculator {
addedFile(text: string): DiffStats {
return { version: 1, added: this.lineCount(text), removed: 0 };
}

deletedFile(text: string): DiffStats {
return { version: 1, added: 0, removed: this.lineCount(text) };
}

update(patch: StructuredPatch): DiffStats | null {
if (patch.isBinary || patch.hunks.length === 0) return null;
let added = 0;
let removed = 0;
let previousOldEnd = 1;
let previousNewEnd = 1;
for (const hunk of patch.hunks) {
const { oldStart, oldLines, newStart, newLines } = hunk;
if (![oldStart, oldLines, newStart, newLines].every(Number.isSafeInteger) ||
oldStart < previousOldEnd || newStart < previousNewEnd || oldLines < 0 || newLines < 0 ||
newStart - oldStart !== added - removed) return null;
let oldConsumed = 0;
let newConsumed = 0;
let previousWasContent = false;
for (const line of hunk.lines) {
switch (line[0]) {
case '+':
added++;
newConsumed++;
previousWasContent = true;
break;
case '-':
removed++;
oldConsumed++;
previousWasContent = true;
break;
case ' ':
case undefined:
oldConsumed++;
newConsumed++;
previousWasContent = true;
break;
case '\\':
if (!previousWasContent || line.replace(/\r$/, '') !== '\\ No newline at end of file') return null;
previousWasContent = false;
break;
default:
return null;
}
}
if (oldConsumed !== oldLines || newConsumed !== newLines) return null;
previousOldEnd = oldStart + oldLines;
previousNewEnd = newStart + newLines;
}
return { version: 1, added, removed };
}

private lineCount(text: string): number {
let count = 0;
for (let offset = text.indexOf('\n'); offset >= 0; offset = text.indexOf('\n', offset + 1)) count++;
for (let offset = text.indexOf('\r'); offset >= 0; offset = text.indexOf('\r', offset + 1)) {
if (text[offset + 1] !== '\n') count++;
}
if (text.length > 0 && !text.endsWith('\n') && !text.endsWith('\r')) count++;
return count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@
"newText": "class FileA\n",
"path": "/test/project/FileA.kt",
"_meta": {
"kind": "add"
"kind": "add",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 1,
"removed": 0
}
}
}
}
},
{
Expand All @@ -25,7 +35,17 @@
"newText": "class FileB\n",
"path": "/test/project/FileB.kt",
"_meta": {
"kind": "add"
"kind": "add",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 1,
"removed": 0
}
}
}
}
}
]
Expand Down
12 changes: 11 additions & 1 deletion src/__tests__/CodexACPAgent/data/file-change-add-new-file.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@
"newText": "package test.project\n\nclass NewFile {\n fun hello() = \"Hello\"\n}\n",
"path": "/test/project/NewFile.kt",
"_meta": {
"kind": "add"
"kind": "add",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 5,
"removed": 0
}
}
}
}
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@
"newText": "fun main() {\n println(\"Hello, World!\")\n}\n",
"path": "/test/project/RawFile.kt",
"_meta": {
"kind": "add"
"kind": "add",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 3,
"removed": 0
}
}
}
}
}
]
Expand Down
12 changes: 11 additions & 1 deletion src/__tests__/CodexACPAgent/data/file-change-delete-file.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@
"newText": "",
"path": "/test/project/OldFile.kt",
"_meta": {
"kind": "delete"
"kind": "delete",
"jetbrains": {
"air": {
"version": 1,
"diffStats": {
"version": 1,
"added": 0,
"removed": 3
}
}
}
}
}
]
Expand Down
Loading