Skip to content

docs(rfd): Next Edit Suggestion (NES)#571

Open
anna239 wants to merge 7 commits intomainfrom
anna.zhdan/nes
Open

docs(rfd): Next Edit Suggestion (NES)#571
anna239 wants to merge 7 commits intomainfrom
anna.zhdan/nes

Conversation

@anna239
Copy link
Contributor

@anna239 anna239 commented Feb 24, 2026

Elevator pitch

What are you proposing to change?

Add a Next Edit Suggestion (NES) capability to ACP, allowing agents to provide predictive code edits. The protocol is designed around capability negotiation: agents declare what events and context they can consume, and clients provide only what was requested.

Status quo

How do things work today and what problems does this cause? Why would we change things?

ACP currently has no mechanism for agents to provide inline edit predictions. Each client–agent pair implements NES through proprietary protocols.

What we propose to do about it

What are you proposing to improve the situation?

Introduce a nes capability that agents advertise during initialization. The capability declares:

  • Events the agent wants to receive (file lifecycle notifications).
  • Context the agent wants attached to each suggestion request.

The client inspects these declarations and provides only what was requested, minimizing overhead for simple agents while allowing rich context for advanced ones.

Capability advertisement

During initialize, the agent includes a nes field in its capabilities:

{
  "capabilities": {
    "nes": {
      "events": {
        "didOpen": {},
        "didChange": {
          "syncKind": "incremental"
        },
        "didClose": {},
        "didSave": {},
        "didFocus": {}
      },
      "context": {
        "recentFiles": {
          "maxCount": 10
        },
        "relatedSnippets": {},
        "editHistory": {
          "maxCount": 6
        },
        "userActions": {
          "maxCount": 16
        },
        "openFiles": {},
        "diagnostics": {}
      }
    }
  }
}

All fields under events and context are optional — an agent advertises only what it can use.

Client capabilities

The client advertises its own NES-related capabilities in the initialize request. Currently, the client can declare which well-known IDE actions it supports by listing their IDs. The agent reads these and may later include "action" kind suggestions that reference them.

{
  "capabilities": {
    "nes": {
      "ideActions": {
        "rename": {},
        "searchAndReplace": {}
      }
    }
  }
}

Each entry in ideActions is the ID of a well-known action (see Well-known IDE actions below). Agents should only suggest actions that the client has advertised.

Session lifecycle

If the nes capability is present, the client may call nes/start to begin an NES session. The agent can also use the existing configOptions mechanism to expose NES-related settings (model selection, debounce preferences, enabled/disabled state, etc.).

Implementation details and plan

Tell me more about your implementation. What is your detailed implementation plan?

Events

Events are fire-and-forget notifications from client to agent. The client sends them only if the corresponding key is present in nes.events.

nes/didOpen

Sent when a file is opened in the editor.

{
  "jsonrpc": "2.0",
  "method": "nes/didOpen",
  "params": {
    "uri": "file:///path/to/file.rs",
    "languageId": "rust",
    "version": 1,
    "text": "fn main() {\n    println!(\"hello\");\n}\n"
  }
}

nes/didChange

Sent when a file is edited. Supports two sync modes declared by the agent:

  • "full" — client sends entire file content each time.
  • "incremental" — client sends only the changed ranges.

Incremental:

{
  "jsonrpc": "2.0",
  "method": "nes/didChange",
  "params": {
    "uri": "file:///path/to/file.rs",
    "version": 2,
    "contentChanges": [
      {
        "range": {
          "start": { "line": 1, "character": 4 },
          "end": { "line": 1, "character": 4 }
        },
        "text": "let x = 42;\n    "
      }
    ]
  }
}

Full:

{
  "jsonrpc": "2.0",
  "method": "nes/didChange",
  "params": {
    "uri": "file:///path/to/file.rs",
    "version": 2,
    "contentChanges": [
      {
        "text": "fn main() {\n    let x = 42;\n    println!(\"hello\");\n}\n"
      }
    ]
  }
}

nes/didClose

Sent when a file is closed.

{
  "jsonrpc": "2.0",
  "method": "nes/didClose",
  "params": {
    "uri": "file:///path/to/file.rs"
  }
}

nes/didSave

Sent when a file is saved.

{
  "jsonrpc": "2.0",
  "method": "nes/didSave",
  "params": {
    "uri": "file:///path/to/file.rs"
  }
}

nes/didFocus

Sent when a file becomes the active editor tab. Unlike nes/didOpen (which fires once when a file is first opened), nes/didFocus fires every time the user switches to a file, including files that are already open. This is the primary trigger for agents that need to refresh context on tab switch (e.g. re-indexing relevant code snippets).

{
  "jsonrpc": "2.0",
  "method": "nes/didFocus",
  "params": {
    "uri": "file:///path/to/file.rs",
    "version": 2,
    "position": { "line": 5, "character": 12 },
    "visibleRange": {
      "start": { "line": 0, "character": 0 },
      "end": { "line": 45, "character": 0 }
    }
  }
}

The position is the current cursor position. The visibleRange is the portion of the file currently visible in the editor viewport.

Suggestion request

The client requests a suggestion by calling nes/suggest. Context fields are included only if the agent declared interest in the corresponding nes.context key.

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "nes/suggest",
  "params": {
    "uri": "file:///path/to/file.rs",
    "version": 2,
    "position": { "line": 5, "character": 12 },
    "selection": {
      "start": { "line": 5, "character": 4 },
      "end": { "line": 5, "character": 12 }
    },
    "triggerKind": "automatic",
    "context": {
      "recentFiles": [
        {
          "uri": "file:///path/to/utils.rs",
          "languageId": "rust",
          "text": "pub fn helper() -> i32 { 42 }\n"
        }
      ],
      "relatedSnippets": [
        {
          "uri": "file:///path/to/types.rs",
          "excerpts": [
            {
              "startLine": 10,
              "endLine": 25,
              "text": "pub struct Config {\n    pub name: String,\n    ...\n}"
            }
          ]
        }
      ],
      "editHistory": [
        {
          "uri": "file:///path/to/file.rs",
          "diff": "--- a/file.rs\n+++ b/file.rs\n@@ -3,0 +3,1 @@\n+    let x = 42;"
        }
      ],
      "userActions": [
        {
          "action": "insertChar",
          "uri": "file:///path/to/file.rs",
          "line": 5,
          "offset": 12,
          "timestampMs": 1719400000000
        },
        {
          "action": "cursorMovement",
          "uri": "file:///path/to/file.rs",
          "line": 10,
          "offset": 0,
          "timestampMs": 1719400001200
        }
      ],
      "openFiles": [
        {
          "uri": "file:///path/to/utils.rs",
          "languageId": "rust",
          "visibleRange": {
            "start": { "line": 0, "character": 0 },
            "end": { "line": 30, "character": 0 }
          },
          "lastFocusedMs": 1719399998000
        },
        {
          "uri": "file:///path/to/types.rs",
          "languageId": "rust",
          "visibleRange": null,
          "lastFocusedMs": 1719399990000
        }
      ],
      "diagnostics": [
        {
          "uri": "file:///path/to/file.rs",
          "range": {
            "start": { "line": 5, "character": 0 },
            "end": { "line": 5, "character": 10 }
          },
          "severity": "error",
          "message": "cannot find value `foo` in this scope"
        }
      ]
    }
  }
}

selection is the current text selection range, if any. When the selection is empty (cursor is a point), this field may be omitted or have start equal to end. Agents can use selection state to predict replacements or transformations of the selected text.

triggerKind is one of:

  • "automatic" — triggered by user typing or cursor movement
  • "diagnostic" — triggered by a diagnostic (error/warning) appearing at or near the cursor position. The client includes the relevant diagnostics in the diagnostics context field so the agent can target a fix.
  • "manual" — triggered by explicit user action (keyboard shortcut)

Suggestion response

A suggestion is one of three kinds: an edit (text changes), a jump (navigate to a different file), or an action (trigger an IDE action).

Edit suggestion:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "suggestions": [
      {
        "id": "sugg_001",
        "kind": "edit",
        "uri": "file:///path/to/other_file.rs",
        "edits": [
          {
            "range": {
              "start": { "line": 5, "character": 0 },
              "end": { "line": 5, "character": 10 }
            },
            "newText": "let result = helper();"
          }
        ],
        "cursorPosition": { "line": 5, "character": 22 }
      }
    ]
  }
}

Jump suggestion:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "suggestions": [
      {
        "id": "sugg_002",
        "kind": "jump",
        "uri": "file:///path/to/other_file.rs",
        "position": { "line": 15, "character": 4 }
      }
    ]
  }
}

Action suggestion:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "suggestions": [
      {
        "id": "sugg_003",
        "kind": "action",
        "actionId": "rename",
        "arguments": {
          "uri": "file:///path/to/file.rs",
          "position": { "line": 5, "character": 10 },
          "newName": "calculateTotal"
        }
      }
    ]
  }
}

Action suggestions reference an IDE action that the client previously advertised in its capabilities:

  • actionId — matches an id from the client's advertised ideActions.
  • arguments — matches the parameter schema declared by the client for that action.

A response may contain a mix of edit, jump, and action suggestions. The client decides how to present them (e.g. inline ghost text for edits, a navigation hint for jumps).

Each suggestion contains:

  • id — unique identifier for accept/reject tracking.
  • kind"edit", "jump", or "action".

Edit suggestions additionally contain:

  • edits — one or more text edits to apply.
  • cursorPosition — optional suggested cursor position after applying edits.

Jump suggestions additionally contain:

  • uri — the file to navigate to.
  • position — the target position within that file.

Action suggestions additionally contain:

  • actionId — the IDE action to perform (must match a client-advertised action id).
  • arguments — action parameters matching the schema from the client's capability.

Accept / Reject

{
  "jsonrpc": "2.0",
  "method": "nes/accept",
  "params": {
    "id": "sugg_001"
  }
}
{
  "jsonrpc": "2.0",
  "method": "nes/reject",
  "params": {
    "id": "sugg_001",
    "reason": "rejected"
  }
}

reason is one of:

  • "rejected" — the user explicitly dismissed the suggestion (e.g. pressed Escape or typed something incompatible).
  • "ignored" — the suggestion was shown but the user continued editing without interacting with it, and the context changed enough to invalidate it.
  • "replaced" — the suggestion was superseded by a newer suggestion before the user could act on it.
  • "cancelled" — the request was cancelled before the agent returned a response (e.g. the user typed quickly and the previous request became stale).

The reason field is optional. If omitted, the agent should treat it as "rejected". Providing granular reasons allows agents to improve their models — for example, a "replaced" suggestion carries different training signal than an explicit "rejected".

NES session start

The client provides workspace metadata when starting a session. This information is static for the lifetime of the session.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "nes/start",
  "params": {
    "workspaceUri": "file:///Users/alice/projects/my-app",
    "workspaceFolders": [
      {
        "uri": "file:///Users/alice/projects/my-app",
        "name": "my-app"
      }
    ],
    "repository": {
      "name": "my-app",
      "owner": "alice",
      "remoteUrl": "https://github.com/alice/my-app.git"
    }
  }
}

All fields in params are optional. The repository field is omitted if the workspace is not a git repository or the info is unavailable.

Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "sessionId": "nes_abc123"
  }
}

Well-known IDE actions

The following actions are well-known and have standardized parameter schemas. Clients that support these actions should use the IDs and parameter shapes defined here.

rename — Rename a symbol across the workspace.

Parameters:

  • uri (string) — the file URI containing the symbol.
  • position (Position) — the position of the symbol to rename.
  • newName (string) — the new name for the symbol.

searchAndReplace — Search and replace text within a file.

Parameters:

  • uri (string) — the file URI to search within.
  • search (string) — the text or pattern to find.
  • replace (string) — the replacement text.
  • isRegex (boolean, optional) — whether search is a regular expression. Defaults to false.

Additional well-known actions may be added to the protocol in the future. Agents should only suggest actions whose id matches an entry the client has advertised.

Config options

The agent can use the existing configOptions mechanism from ACP to expose NES-related settings. For example, an agent might return config options like:

{
  "configOptions": [
    {
      "id": "nes_model",
      "name": "Prediction Model",
      "category": "model",
      "type": "enum",
      "currentValue": "fast",
      "options": [
        { "value": "fast", "label": "Fast" },
        { "value": "accurate", "label": "Accurate" }
      ]
    }
  ]
}

Frequently asked questions

What questions have arisen over the course of authoring this document or during subsequent discussions?

Why separate events from context?

Events and context serve different purposes and have different delivery models:

  • Events are pushed as they happen — they allow the agent to maintain internal state (like an LSP server tracking open documents). This is the model Copilot uses.
  • Context is attached to each request — it allows stateless agents to receive everything they need in one call. This is the model Zed Predict and Supermaven use.

A note about Cursor: Cursor has a separate context-collection phase (RefreshTabContext) that involves vector DB lookup and is triggered on file open, tab switch, and significant edits. The event-based approach supports this flow: an NES agent can listen for nes/didOpen, nes/didFocus, and accumulated nes/didChange events to self-trigger its own context refresh. The nes/didFocus event (with cursor position and visible range) and workspace metadata from nes/start provide all the inputs Cursor's RefreshTabContext needs.

An agent may want both (events for incremental file tracking + context for edit history), or just one. The capability split lets each agent pick the model that fits its architecture.

Why not reuse LSP's textDocument/didOpen etc. directly?

LSP's document sync notifications carry the same information, but:

  1. ACP is not LSP — reusing method names could cause confusion in implementations that bridge both.
  2. We may want to evolve the event payloads independently (e.g. adding metadata fields).
  3. Keeping them namespaced under nes/ makes capability negotiation cleaner.

How does this relate to PR #325?

This RFD covers the session lifecycle and also suggests a protocol that would cover a variety of different nes providers

Why provide workspace info in nes/start?

Agents that perform server-side indexing (embedding-based retrieval, semantic search) need to know which repository they're working with. This metadata — workspace root, repo name/owner, remote URL — is static for the session lifetime, so it belongs in the session start rather than being repeated on every request or requiring a separate query.

What alternative approaches did you consider?

  1. Context-only — Pass all file content, edit history, and metadata as context fields on each nes/suggest request, with no event notifications. This is simpler for stateless agents but forces the client to assemble and transmit potentially large payloads on every request, even when nothing changed. It also prevents agents from maintaining their own incremental state (e.g. an internal file mirror or semantic index).
  2. Events-only — Rely entirely on event notifications (didOpen, didChange, etc.) and have the agent maintain all state internally, with nes/suggest sending only the cursor position. This is efficient on the wire but requires every agent to implement stateful document tracking, which is a high barrier for simple agents that just want the code around the cursor.
  3. Events + context (chosen) — Allow agents to declare both. An agent that wants to track files incrementally can request events; an agent that prefers stateless request-response can request context fields; a sophisticated agent can use both (events for file sync, context for edit history and definition excerpts). This gives each agent the flexibility to pick the model that fits its architecture without imposing unnecessary complexity.

Revision history

  • 2026-02-22: Initial draft

@anna239 anna239 requested a review from a team as a code owner February 24, 2026 09:55
@benbrandt benbrandt changed the title Anna.zhdan/nes docs(rfd): Next Edit Suggestion (NES) Feb 24, 2026

### Session lifecycle

If the `nes` capability is present, the client may call `nes/start` to begin an NES session. The agent can also use the existing `configOptions` mechanism to expose NES-related settings (model selection, debounce preferences, enabled/disabled state, etc.).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to clarify how to handle Auth.
End user may not open the Agent Chat panel during an IDE session. User may have not login yet, and just start type in editor area and want to get NES feature.
Just like session/new, nes/start need to check whether auth is required, and retrun auth_required error.
Auth could be shared — authenticate once, use for both NES and Chat

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User may have not login yet, and just start type in editor area and want to get NES feature.

I think that from the protocol perspective we can handle auth the same way — auth_required will be thrown if user should authenticate first. Then it would be a UX question for the client how it wants to handle it — either by showing a login dialog during setup, or via popup or some other way

Copy link
Member

@benbrandt benbrandt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our Edit Prediction folks thought this looked good!

One question came up: Some of these events / context might also be useful outside of the edit prediction case (events around file edits for example might be useful to a coding agent)

Not sure what you think about that, but it might be interesting to see which of these would be NES specific and which might be more generally useful (if the agent opts-in)

@anna239
Copy link
Contributor Author

anna239 commented Mar 6, 2026

might also be useful outside of the edit prediction case

It's funny that I've just discussed the same thing with a colleague yesterday, that it would be nice to inform the agent that a file that agent has read before has changed. Should we maybe change the names for the "nes/didChange", "nes/didFocus" for something more generic, like "editor/didChange" ? Then we'll be able to allow this methods not only in nes sessions in the future

@hancengiz
Copy link
Contributor

nice rfd.

I see the client already announces NES capabilities during initialize with ideActions — that's the right approach. just want to reinforce that this should be the hard gate for the entire NES surface. if client doesn't announce NES support, agent should not send anything NES related — no events, no suggestions, nothing. zero noise on the stream.

there's a growing category of ACP clients that don't do keystroke typing at all. tools like fabriqa and toad are ACP clients where the ai manages files directly, no editor, no cursor. autonomous agentic orchestrators are also increasingly using ACP. these clients will never need NES and shouldn't have to deal with ignoring messages they didn't ask for.

NES makes total sense for IDEs and text editors like zed and jetbrains. just make sure client-side capability announcement is the single source of truth for whether NES is active or not.

```json
{
"capabilities": {
"nes": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok nitpick, but I keep thinking about nintendo when I see nes... maybe I'm just old 😜

I wonder if we could use the namespace next_edit or something? Downside is we usually do snake case next_edit/* in methods and it would be nextEdit in capabilities, but I guess that is consistent with other methods so it is fine.

Doesn't have to be a blocker, but after several days it still throws me off haha

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was no Nintendos in my childhood =) so I don't have this association. If you feel we should switch to next_edit — we can do that

```json
{
"capabilities": {
"nes": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think these would be better top-level as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the ide actions? But maybe they are somehow NES specific?


### Position encoding

All `Position` objects in NES use zero-based line and zero-based character offsets, following the same conventions as [LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position). The meaning of the `character` offset depends on the negotiated **position encoding**.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: One thing we'll need to be careful of is so far all line numbers have been 1-based in the protocol


Three encoding kinds are supported:

- `"utf-16"` — character offsets count UTF-16 code units. This is the **default** and must be supported by all clients and agents.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another difference as far as I know. I think this is a difference between LSP and the other protocols.
I assume this is just in terms of the position units though and shouldn't affect the fact the the protocol requires all json-rpc messages to be utf-8 encoded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it's only for positions. I've talked to the team that does NES in JetBrains and they claimed it's super-important thing to support — they've encountered some related problems already

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok as long as we don't have to change the encoding of messages I think this is ok

{
"capabilities": {
"nes": { ... },
"positionEncoding": "utf-32"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this basically captures the "decision" of what both sides should use?
we could I guess also add this to the position type itself but it might be noisy. I guess we can keep an eye on it during implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I wanted to keep it similar to lsp spec

"uri": "file:///path/to/file.rs",
"languageId": "rust",
"version": 1,
"text": "fn main() {\n println!(\"hello\");\n}\n"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess all of these will still always be utf-8 encoded?

- `"replaced"` — the suggestion was superseded by a newer suggestion before the user could act on it.
- `"cancelled"` — the request was cancelled before the agent returned a response (e.g. the user typed quickly and the previous request became stale).

The `reason` field is optional. If omitted, the agent should treat it as `"rejected"`. Providing granular reasons allows agents to improve their models — for example, a `"replaced"` suggestion carries different training signal than an explicit `"rejected"`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to have the default be "rejected"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, not really, what do you think the default should be? Or do you want to make the field mandatory?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I guess it just is weird that we default to the one that has strong user intent vs a more passive one, but I'm not familiar with how others handle this

Parameters:

- `uri` (`string`) — the file URI containing the symbol.
- `position` (`Position`) — the position of the symbol to rename.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would be a potential reason not to expose this method outside of NES context, since it is tied to this Position type.
Though maybe it still works if we have a good story about these different utf encodings or allow for the position to have a type of the encoding it maps to

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants