AI diagram editing: validated operation schema, 'Edit Diagram with AI' command (vscode.lm) with preview, applyOperations API
Feature: AI Diagram Editing via Structured Operations
Motivation
Users and AI agents should be able to modify Diagrammer diagrams with natural-language instructions. Each instruction is translated into small, validated, structured operations against the native diagram model. The LLM never rewrites the whole diagram JSON.
Scope
1. Operation model (src/ai/operations.ts or equivalent)
- Define a TypeScript discriminated union and a matching JSON Schema with a
schemaVersionfield. The operations are:addNoderemoveNode(also removes attached connectors)renameNodemoveNodeaddConnectorremoveConnectorupdateNodeMetadataupdateConnectorMetadataapplyLayout
- Operations reference existing elements by ID.
- New nodes use a caller-supplied
tempId. Later operations in the same batch can reference thattempId. - Provide a validator that rejects:
- unknown operation types
- references to missing IDs
- duplicate IDs or tempIds
- connectors to nonexistent nodes
- malformed fields
- non-array or unparseable input
- Provide a pure function
applyOperations(diagram, ops) -> { diagram, summary } | { errors }.- It is atomic: on any failure, the original diagram is returned unchanged and is not mutated.
- This core logic must not depend on
vscode, so a future MCP server can reuse it.
groupNodesis included only if the current native format already supports groups or containers. Otherwise it is omitted, and docs/operations.md says so. Do not change the file format.applyLayoutdelegates to the existing auto-layout from #14 if it exists. If no layout exists, omitapplyLayoutand document the omission. Do not build a layout engine.
2. Natural-language command
- Register
Diagrammer: Edit Diagram with AI(diagrammer.editWithAI). It is enabled only when a Diagrammer custom editor is active, via awhenclause. - The command prompts for an instruction with an input box.
- The prompt includes:
- the current diagram (nodes and connectors with IDs, labels and metadata)
- the selected node IDs and labels, clearly labelled as the selection
- the operation JSON Schema
- an instruction to return only a JSON array of operations
- The default provider uses the VS Code Language Model API (
vscode.lm), wrapped behind aDiagramAIProviderinterface.- Do not add API-key settings, secret storage or custom HTTP client code.
- If no model is available, show an informative error.
- The response is parsed and validated. Tolerating surrounding code fences is acceptable. On failure, show an error message and leave the document untouched.
3. Selection awareness
- The webview posts the current selection (an array of node IDs) to the extension host whenever it changes.
- Use the existing selection model.
- If only single selection exists, report that single selection. Do not build a new multi-select UX.
4. Preview and confirm
- Every AI batch shows a human-readable summary before it is applied, for example:
- Add 3 nodes
- Remove 1 connector
- Rename "API" → "Public API"
- Connect "Worker" → "Queue"
- Use a modal message or QuickPick with Apply and Cancel.
- If auto-layout exists, also offer "Keep positions" or "Re-layout".
- Cancel or dismiss leaves the document unchanged.
5. Persistence
- Applied changes go through the existing document edit path, so they persist in the existing file format and undo works.
6. External agent entry point
- Register the command
diagrammer.applyOperations(uri?, ops).- If
uriis omitted, it uses the active Diagrammer document. - It validates and applies the operations with no LLM call and no preview UI.
- It returns
{ summary }or{ errors }.
- If
- No MCP server is included.
7. Documentation
- Add
docs/operations.mdcovering:- each operation's schema
- ID and tempId rules
- the schema version
- an example batch
- how to call
diagrammer.applyOperations - any omitted operations (groupNodes, applyLayout) and why
Acceptance criteria
- Unit tests cover
applyOperationsfor each implemented operation type, including removing a node with attached connectors and using tempIds. - Unit tests verify that a batch with one invalid operation leaves the diagram byte-identical (serialized form equal) and unmutated.
- Unit tests verify that the validator rejects unknown types, missing IDs, duplicate IDs and malformed JSON.
- A unit test with a mocked provider verifies that the prompt contains the diagram, the selected node IDs and the schema.
- With a mocked provider returning valid operations, the command shows a summary, and Cancel leaves the document unchanged.
- With the same mocked provider, Apply updates the document.
- Unparseable or invalid LLM output shows an error and does not modify the document.
-
diagrammer.applyOperationsapplies valid operations and returns errors for invalid ones, without any LLM call. -
docs/operations.mdexists and matches the implemented schema. - No AI vendor SDK is added as a dependency. The provider sits behind
DiagramAIProvider. - The existing build, lint and tests pass.
Out of scope
- An MCP server
- OpenAI-compatible or other providers, API-key settings and secret storage
- New auto-layout or multi-select UX
- File format changes, including grouping
- Repository-wide code modification
- Voice control
- Real-time collaboration
- Applying changes without review in the interactive command
Model claude-opus-5-5 · ceiling $5.25 · started 1 hour ago · finished 1 hour ago
- `src/ai/vscodeLmProvider.ts` is the only provider: it uses `vscode.lm.selectChatModels` and `sendRequest`. There is no vendor SDK, API-key setting or secret storage.
- `src/ai/editSession.ts` runs the flow: ask for an instruction → call the model (with cancellable progress) → check the reply → show the summary → apply only after confirmation.
- If the diagram changes while the summary is open, the batch is checked again against the current diagram before it is applied.
**Commands: `src/ai/commands.ts`, `src/extension.ts`, `package.json`**
- `diagrammer.editWithAI` ("Diagrammer: Edit Diagram with AI") only shows in the Command Palette while a Diagrammer editor is active.
- The summary appears in a modal dialog with **Apply (keep positions)**, **Apply and Re-layout** and Cancel.
- `diagrammer.applyOperations(uri?, ops)` returns `{ summary, idMap }` or `{ errors }`. It is hidden from the Command Palette because it needs arguments.
- Both commands save changes through the document's existing edit path, so undo, dirty state and saving work as before.
**Selection reporting: `src/webview/main.ts`, `src/protocol.ts`, `src/diagramEditor.ts`**
- The webview sends a new `{type:'selection', nodeIds}` message only when the selection changes, using the existing single-selection model.
- The editor stores the selection for each panel. It also gained `findDocument`, `selectedNodeIds` and `applyDocumentEdit`; auto-layout now uses `applyDocumentEdit` too.
**Docs**
- New `docs/operations.md` covers each operation's fields, the id/tempId rules, the schema version, an example batch, how to call `diagrammer.applyOperations`, and why `groupNodes` is omitted.
- `README.md` has a new "AI editing" section and updated project-layout notes.
### Tests
- `src/test/unit/operations.test.ts`:
- every operation type, including removing a node with its connectors and using tempIds
- an invalid batch leaves the diagram byte-identical (serialized form equal) and unmodified, including a frozen input
- the validator rejects unknown types, missing ids, duplicate tempIds, malformed fields and bad JSON
- the schema matches the validator
- the docs list every operation, and the example batch in the docs produces exactly the summary shown there
- `src/test/unit/aiEdit.test.ts`, with a fake provider and fake dialogs:
- the prompt contains the diagram, the selected node ids and the schema
- Cancel shows the summary and leaves the document unchanged
- Apply updates the document; Re-layout also moves the nodes
- unparseable or invalid output shows an error and changes nothing
- also covered: no model available, an empty instruction, an empty reply, and the diagram changing before Apply
- `src/test/unit/webview.test.ts`: a new test for selection messages. Two existing "no messages sent" checks now allow the new selection message.
- `src/test/integration/suite/extension.test.ts`: checks that both commands are registered, and that `applyOperations` rejects an invalid batch and applies a valid one with working undo, redo and save. **Not run** (see above).
### Worth a close look
- **VS Code version:** `engines.vscode` and `@types/vscode` are raised from 1.85 to **1.90**, the first release where `vscode.lm` is stable. `package-lock.json` changes accordingly.
- **Metadata meaning:** the file format has no free-form metadata. So `updateNodeMetadata` changes a node's shape type and size, and `updateConnectorMetadata` changes a connector's label. This is documented in `docs/operations.md`.
- **Model choice:** the first model returned by `vscode.lm.selectChatModels()` is used; there is no model picker.
- **Stricter connectors:** self-loops and duplicate connectors are rejected as errors. The existing model functions silently ignore them.
2026-10-03T19:57:06Z [claude] I've built the AI diagram editing feature and pushed it to `nabled/feature-15-job-15`. The 118 unit tests, lint, type-check and build all pass. The new VS Code integration test has not been run, because the sandbox couldn't download VS Code; please run `xvfb-run -a npm run test:integration` in CI.
**What it does:**
- **Edit Diagram with AI** (`diagrammer.editWithAI`) only shows while a Diagrammer editor is open. You type an instruction and a VS Code language model (via `vscode.lm`) turns it into small edit operations.
- The prompt sends the diagram, the selected node and the operation schema.
- Nothing is applied until you confirm in a dialog listing the changes (e.g. *Rename "API" → "Public API"*). You can choose **Apply (keep positions)**, **Apply and Re-layout** or Cancel.
- **Bad output:** if the model's reply can't be read or contains invalid operations, you get an error and the diagram is not touched. If no model is available, the error says so.
- **Atomic batches:** the nine operations are checked first, and if any one is invalid nothing changes. `groupNodes` is left out because the file format has no groups; `applyLayout` uses the existing auto layout.
- **`diagrammer.applyOperations(uri?, ops)`** lets other tools apply the same operations directly, with no model call or dialog. It returns a summary of the changes or a list of errors.
- **Undo and saving** work as for any other edit, and the file format is unchanged.
- **`docs/operations.md`** documents every operation, the id rules, the schema version, an example batch and why `groupNodes` is missing. A test checks that the example in the docs still works.
**Things to review:**
- **VS Code version:** the extension now requires VS Code **1.90** (up from 1.85), the first version where the language model API is stable.
- **Metadata:** the file format has no free-form metadata, so updating node metadata means changing shape and size, and updating connector metadata means changing its label.
- **Model choice:** the first available chat model is used; there is no way to pick a different one.
- **Stricter connectors:** connecting a node to itself, or adding the same connection twice, is now reported as an error. The existing editor code silently ignores these.
The pull request description lists every changed file and test.
2026-10-03T19:57:08Z Changed files:
modified README.md +27 -2
added docs/operations.md +139 -0
modified package-lock.json +4 -4
modified package.json +20 -2
added src/ai/commands.ts +90 -0
added src/ai/editSession.ts +92 -0
added src/ai/operations.ts +597 -0
added src/ai/prompt.ts +69 -0
added src/ai/provider.ts +21 -0
added src/ai/vscodeLmProvider.ts +34 -0
modified src/diagramEditor.ts +30 -2
modified src/extension.ts +3 -0
modified src/protocol.ts +3 -1
modified src/test/integration/suite/extension.test.ts +46 -0
added src/test/unit/aiEdit.test.ts +208 -0
added src/test/unit/operations.test.ts +383 -0
modified src/test/unit/webview.test.ts +25 -2
modified src/webview/main.ts +13 -0
2026-10-03T19:57:09Z Opened pull request https://github.com/nabledhq/diagrammerext/pull/3
2026-10-03T19:57:09Z Finished: success=true turns=34 tokens(in/out)=2492381/54817 list cost=$2.12
Show patch
diff --git a/README.md b/README.md
index 696a696..24998a5 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,10 @@ reviewed, diffed and versioned like any other file.
- **Auto layout** – arrange the whole diagram automatically with [dagre](https://github.com/dagrejs/dagre)
(see [Auto layout](#auto-layout) below), from the toolbar button or the Command Palette. Nodes
written without coordinates are placed automatically when the file is opened.
+- **Edit Diagram with AI** – describe a change in plain language; a VS Code language model
+ (e.g. GitHub Copilot) turns it into validated edit operations, you review a summary, and only then
+ is it applied as one undoable edit. Agents can call `diagrammer.applyOperations` directly. See
+ [AI editing](#ai-editing).
- **VS Code integration** – edits mark the file dirty, `Ctrl+S`/`Cmd+S` saves, and
`Ctrl+Z`/`Ctrl+Y` (or `Cmd+Z`/`Cmd+Shift+Z`) undo and redo through VS Code's edit history. Hot
exit/backups, *Save As* and *Revert File* are supported. Colours follow the active VS Code theme.
@@ -97,6 +101,24 @@ The layout engine (`src/layout/index.ts`) is plain TypeScript with no VS Code de
script or a future CLI/MCP tool can call `computeLayout`, `autoLayout` or `placeUnpositioned`
directly.
+## AI editing
+
+- **`Diagrammer: Edit Diagram with AI`** (`diagrammer.editWithAI`, while a Diagrammer editor is
+ active) asks for an instruction, sends the diagram, the selected node and the operation schema to
+ the first chat model available through the VS Code Language Model API, validates the returned
+ operations and shows a summary (*Add 1 node: "Cache"*, *Rename "API" → "Public API"*, …) with
+ **Apply (keep positions)**, **Apply and Re-layout** and **Cancel**. Invalid model output is
+ reported and never changes the diagram. No API keys are needed or stored; you need an extension
+ that provides chat models, such as GitHub Copilot Chat.
+- **`diagrammer.applyOperations`** – `executeCommand('diagrammer.applyOperations', uri?, operations)`
+ validates and applies a batch of operations without any model call or UI and returns
+ `{ summary, idMap }` or `{ errors }`.
+
+Operations (`addNode`, `removeNode`, `renameNode`, `moveNode`, `addConnector`, `removeConnector`,
+`updateNodeMetadata`, `updateConnectorMetadata`, `applyLayout`), the id/tempId rules and the JSON
+Schema are documented in [docs/operations.md](docs/operations.md). Batches are atomic and applied
+through the normal edit path, so undo and saving work as usual.
+
## File format
```json
@@ -131,7 +153,7 @@ the format (invalid JSON, wrong types, unknown node types, duplicate ids) is rep
## Running the extension
-Requirements: Node.js 20.19+ (or 22.13+) and VS Code 1.85+.
+Requirements: Node.js 20.19+ (or 22.13+) and VS Code 1.90+.
```bash
npm install
@@ -149,7 +171,7 @@ Development Host. In it, run **Diagrammer: New Diagram** from the Command Palett
| `npm run compile` | Type-check (`tsc --noEmit`) and bundle the extension and webview with esbuild. |
| `npm run watch` | Rebuild on change. |
| `npm run lint` | Run ESLint (typescript-eslint) over `src/`. |
-| `npm test` | Compile and run the unit tests with Mocha (model, layout engine and webview canvas in jsdom). |
+| `npm test` | Compile and run the unit tests with Mocha (model, layout engine, AI operations and edit flow, webview canvas in jsdom). |
| `npm run test:integration` | Launch VS Code via `@vscode/test-electron` and run the end-to-end tests. Downloads VS Code on first run; on Linux CI wrap it in `xvfb-run -a`. |
| `npm run package` | Production (minified) bundle. |
@@ -159,6 +181,9 @@ Project layout:
- `src/diagramEditor.ts` – `CustomEditorProvider`, document model, save/revert/backup, undo/redo.
- `src/newDiagram.ts` – the `Diagrammer: New Diagram` command.
- `src/layout/index.ts` – pure auto-layout engine (dagre), usable without VS Code.
+- `src/ai/` – AI editing: `operations.ts` (pure operation schema, validator and `applyOperations`),
+ `prompt.ts`, `editSession.ts` (the confirm-before-apply flow), `provider.ts`
+ (`DiagramAIProvider`), `vscodeLmProvider.ts` (`vscode.lm`) and `commands.ts`.
- `src/model/diagram.ts` – pure diagram model: parsing/validation, serialization, edits, geometry.
Shared by the extension host and the webview.
- `src/webview/main.ts`, `media/diagram.css` – the SVG canvas (no third-party diagram library;
diff --git a/docs/operations.md b/docs/operations.md
new file mode 100644
index 0000000..72da737
--- /dev/null
+++ b/docs/operations.md
@@ -0,0 +1,139 @@
+# Diagram operations (schema version 1)
+
+AI agents, scripts and the **Diagrammer: Edit Diagram with AI** command change diagrams through
+small, validated operations instead of rewriting the `.diagram.json` file. A *batch* is a JSON
+array of operation objects. Batches are applied in order and atomically: if any operation is
+invalid, nothing changes and every problem found is reported.
+
+The implementation lives in [`src/ai/operations.ts`](../src/ai/operations.ts) and has no VS Code
+dependency (`applyOperations`, `parseOperations`, `validateOperations`, `OPERATIONS_JSON_SCHEMA`),
+so a script or a future MCP server can reuse it.
+
+## Schema version
+
+The JSON Schema (draft 2020-12) is exported as `OPERATIONS_JSON_SCHEMA` and carries
+`"schemaVersion": 1` (also exported as `OPERATIONS_SCHEMA_VERSION`). It is generated from the same
+field table the validator uses, so the two always agree. The version will be bumped on any
+incompatible change. Batches themselves are plain arrays and carry no version field.
+
+## Operations
+
+Every operation is an object with an `op` field. Unknown `op` values and unknown fields are
+rejected. "Ref" means an existing element id or a `tempId` defined earlier in the same batch.
+
+| `op` | Required fields | Optional fields | Effect |
+| --- | --- | --- | --- |
+| `addNode` | `tempId`, `label` | `type`, `x`, `y`, `width`, `height` | Adds a node. |
+| `removeNode` | `id` (node ref) | | Removes the node **and every connector attached to it**. |
+| `renameNode` | `id` (node ref), `label` | | Sets the node's label. |
+| `moveNode` | `id` (node ref), `x`, `y` | | Moves the node's top-left corner. |
+| `addConnector` | `from`, `to` (node refs) | `label`, `tempId` | Adds a directed connector `from` → `to`. |
+| `removeConnector` | `id` (connector ref) | | Removes the connector. |
+| `updateNodeMetadata` | `id` (node ref), and at least one optional field | `type`, `width`, `height` | Changes the node's shape and/or size. |
+| `updateConnectorMetadata` | `id` (connector ref), `label` | | Sets the connector's label; `""` removes it. |
+| `applyLayout` | | `mode` | Re-arranges the whole diagram with auto layout. |
+
+Field types:
+
+| Field | Type |
+| --- | --- |
+| `id`, `from`, `to`, `tempId` | Non-empty string. |
+| `label` | String (may contain `\n`). |
+| `type` | One of `rectangle`, `roundedRectangle`, `ellipse`, `diamond`, `text`, `sticky`. Defaults to `rectangle` for `addNode`. |
+| `x`, `y` | Finite number (canvas pixels). For `addNode` give both or neither. |
+| `width`, `height` | Finite number ≥ 10. For `addNode` they default to the shape's standard size. |
+| `mode` | `top-to-bottom` (default) or `left-to-right`. |
+
+The file format has no free-form metadata, so "metadata" means the existing attributes other than
+the label and position: shape type and size for nodes, the label for connectors.
+
+Additional rules checked when the batch is applied:
+
+- A connector cannot connect a node to itself, and two connectors cannot have the same `from`/`to`
+ pair.
+- Elements removed earlier in the batch count as missing for later operations.
+- `addNode` without `x`/`y` places the node automatically after the whole batch (below the
+ existing nodes, without overlaps), unless a later `moveNode` or `applyLayout` positions it.
+
+## Ids and tempIds
+
+- Existing nodes and connectors are referenced by their `id` from the file.
+- New nodes **must**, and new connectors **may**, get a caller-chosen `tempId`. Later operations
+ in the same batch use the `tempId` wherever a ref is expected.
+- A `tempId` must be unique within the batch and must not equal any id already in the diagram.
+- The saved id is generated (`node-N` / `edge-N`, never colliding with existing ids or tempIds).
+ The mapping is returned as `idMap` (`{ "<tempId>": "<saved id>" }`). tempIds are not valid
+ outside their batch.
+- Referencing an unknown id, or a tempId before the operation that defines it, is an error.
+
+## Example batch
+
+Against a diagram with nodes `api` ("API") and `db` ("Database"):
+
+```json
+[
+ { "op": "renameNode", "id": "api", "label": "Public API" },
+ { "op": "addNode", "tempId": "worker", "label": "Worker", "type": "roundedRectangle" },
+ { "op": "addNode", "tempId": "queue", "label": "Queue" },
+ { "op": "addConnector", "from": "api", "to": "queue", "label": "enqueue" },
+ { "op": "addConnector", "from": "worker", "to": "queue" },
+ { "op": "addConnector", "from": "worker", "to": "db" },
+ { "op": "updateNodeMetadata", "id": "db", "type": "ellipse" }
+]
+```
+
+Summary shown/returned for this batch:
+
+```
+Add 2 nodes: "Worker", "Queue"
+Rename "API" → "Public API"
+Connect "Public API" → "Queue" ("enqueue")
+Connect "Worker" → "Queue"
+Connect "Worker" → "Database"
+Update "Database": shape ellipse
+```
+
+## `diagrammer.applyOperations`
+
+Entry point for other extensions, scripts and agents. It validates and applies a batch to an open
+Diagrammer document with no language-model call and no confirmation UI:
+
+```ts
+const result = await vscode.commands.executeCommand('diagrammer.applyOperations', uri, operations);
+// or, for the diagram in the active Diagrammer editor:
+const result = await vscode.commands.executeCommand('diagrammer.applyOperations', operations);
+```
+
+- `uri` (optional) – the `vscode.Uri` of a diagram that is open in the Diagrammer editor. When
+ omitted, the active Diagrammer editor is used.
+- `operations` – the batch, as an array or as a JSON string.
+- Returns `{ summary: string[], idMap: Record<string, string> }` on success, or
+ `{ errors: string[] }` (and no change) when the batch is invalid or no diagram is open.
+
+A successful batch is applied as a single edit through the document's normal edit path: the file
+becomes dirty, one undo reverts the whole batch, and saving writes the usual `.diagram.json` format.
+
+## Edit Diagram with AI
+
+**Diagrammer: Edit Diagram with AI** (`diagrammer.editWithAI`, available while a Diagrammer
+editor is active) asks for an instruction and sends the current diagram (ids, labels, types,
+positions, sizes, connectors), the selected node ids and labels (labelled as the selection), the
+JSON Schema above and the instruction to the first chat model offered by the VS Code Language
+Model API (`vscode.lm`, for example GitHub Copilot). The model must answer with only a JSON array
+of operations; surrounding Markdown code fences are tolerated. There are no API-key settings: if
+no model is available an error explains how to get one.
+
+The reply is validated like any other batch. Invalid or unparseable replies show an error and
+leave the document untouched. Valid replies are previewed in a modal dialog listing the summary,
+with **Apply (keep positions)**, **Apply and Re-layout** (appends an `applyLayout` operation) and
+**Cancel**. Nothing changes unless you choose one of the Apply buttons.
+
+The model access sits behind the `DiagramAIProvider` interface (`src/ai/provider.ts`); the VS Code
+implementation is `src/ai/vscodeLmProvider.ts`. No AI vendor SDK is used.
+
+## Omitted operations
+
+- **`groupNodes`** – not provided. The native `.diagram.json` format (version 1) has no groups or
+ containers, and this feature deliberately does not change the file format.
+- **`applyLayout`** *is* provided: it delegates to the existing dagre-based auto layout
+ (`src/layout/index.ts`); no new layout engine was added.
diff --git a/package-lock.json b/package-lock.json
index c6601af..e4b2a41 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"@types/jsdom": "^30.0.0",
"@types/mocha": "^10.0.10",
"@types/node": "^20.19.43",
- "@types/vscode": "~1.85.0",
+ "@types/vscode": "~1.90.0",
"@vscode/test-electron": "^3.1.0",
"esbuild": "^0.28.2",
"eslint": "^9.39.5",
@@ -970,9 +970,9 @@
"license": "MIT"
},
"node_modules/@types/vscode": {
- "version": "1.85.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.85.0.tgz",
- "integrity": "sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==",
+ "version": "1.90.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz",
+ "integrity": "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index ea94351..5644938 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"url": "https://github.com/nabledhq/diagrammerext"
},
"engines": {
- "vscode": "^1.85.0"
+ "vscode": "^1.90.0"
},
"categories": [
"Visualization",
@@ -51,6 +51,16 @@
"command": "diagrammer.autoLayoutLeftToRight",
"title": "Auto Layout Left to Right",
"category": "Diagrammer"
+ },
+ {
+ "command": "diagrammer.editWithAI",
+ "title": "Edit Diagram with AI",
+ "category": "Diagrammer"
+ },
+ {
+ "command": "diagrammer.applyOperations",
+ "title": "Apply Diagram Operations",
+ "category": "Diagrammer"
}
],
"menus": {
@@ -66,6 +76,14 @@
{
"command": "diagrammer.autoLayoutLeftToRight",
"when": "activeCustomEditorId == diagrammer.diagramEditor"
+ },
+ {
+ "command": "diagrammer.editWithAI",
+ "when": "activeCustomEditorId == diagrammer.diagramEditor"
+ },
+ {
+ "command": "diagrammer.applyOperations",
+ "when": "false"
}
]
}
@@ -87,7 +105,7 @@
"@types/jsdom": "^30.0.0",
"@types/mocha": "^10.0.10",
"@types/node": "^20.19.43",
- "@types/vscode": "~1.85.0",
+ "@types/vscode": "~1.90.0",
"@vscode/test-electron": "^3.1.0",
"esbuild": "^0.28.2",
"eslint": "^9.39.5",
diff --git a/src/ai/commands.ts b/src/ai/commands.ts
new file mode 100644
index 0000000..3149992
--- /dev/null
+++ b/src/ai/commands.ts
@@ -0,0 +1,90 @@
+import * as vscode from 'vscode';
+import type { DiagramEditorProvider } from '../diagramEditor';
+import { AIEditOutcome, AIEditUI, runAIEdit } from './editSession';
+import { applyOperations } from './operations';
+import type { DiagramAIProvider } from './provider';
+
+export const EDIT_WITH_AI_COMMAND = 'diagrammer.editWithAI';
+export const APPLY_OPERATIONS_COMMAND = 'diagrammer.applyOperations';
+
+export type ApplyOperationsCommandResult = { summary: string[]; idMap: Record<string, string> } | { errors: string[] };
+
+const APPLY = 'Apply (keep positions)';
+const RELAYOUT = 'Apply and Re-layout';
+
+const vscodeUI: AIEditUI = {
+ askInstruction: async () =>
+ vscode.window.showInputBox({
+ title: 'Edit Diagram with AI',
+ prompt: 'Describe the change. Selected nodes are sent as context.',
+ placeHolder: 'e.g. Add a Redis cache between "API" and "Database"',
+ ignoreFocusOut: true,
+ }),
+ withProgress: async (task) =>
+ vscode.window.withProgress(
+ { location: vscode.ProgressLocation.Notification, title: 'Asking the language model…', cancellable: true },
+ (_progress, token) => task(token),
+ ),
+ confirm: async (summary) => {
+ const choice = await vscode.window.showInformationMessage(
+ 'Apply these AI changes to the diagram?',
+ { modal: true, detail: summary.map((line) => `• ${line}`).join('\n') },
+ APPLY,
+ RELAYOUT,
+ );
+ return choice === APPLY ? 'apply' : choice === RELAYOUT ? 'relayout' : undefined;
+ },
+ showError: (message) => void vscode.window.showErrorMessage(message),
+ showInfo: (message) => void vscode.window.showInformationMessage(message),
+};
+
+/** Registers `diagrammer.editWithAI` and `diagrammer.applyOperations`. */
+export function registerAICommands(
+ context: vscode.ExtensionContext,
+ editors: DiagramEditorProvider,
+ aiProvider: DiagramAIProvider,
+): void {
+ context.subscriptions.push(
+ vscode.commands.registerCommand(EDIT_WITH_AI_COMMAND, async (uri?: unknown): Promise<AIEditOutcome> => {
+ const document = editors.findDocument(uri instanceof vscode.Uri ? uri : undefined);
+ if (!document) {
+ void vscode.window.showInformationMessage('Open a diagram in the Diagrammer editor to edit it with AI.');
+ return 'cancelled';
+ }
+ if (document.parseError) {
+ void vscode.window.showWarningMessage(`Cannot edit ${document.uri.fsPath}: ${document.parseError}`);
+ return 'failed';
+ }
+ const target = {
+ get diagram() {
+ return document.diagram;
+ },
+ selectedNodeIds: editors.selectedNodeIds(document),
+ applyEdit: (label: string, diagram: typeof document.diagram) =>
+ editors.applyDocumentEdit(document, label, diagram),
+ };
+ return runAIEdit(target, aiProvider, vscodeUI);
+ }),
+ vscode.commands.registerCommand(
+ APPLY_OPERATIONS_COMMAND,
+ (uriOrOps?: unknown, maybeOps?: unknown): ApplyOperationsCommandResult => {
+ const hasUri = uriOrOps instanceof vscode.Uri;
+ const document = editors.findDocument(hasUri ? uriOrOps : undefined);
+ if (!document) {
+ return { errors: ['No open Diagrammer document. Open the diagram or pass the URI of an open one.'] };
+ }
+ if (document.parseError) {
+ return { errors: [`Cannot edit ${document.uri.fsPath}: ${document.parseError}`] };
+ }
+ const result = applyOperations(document.diagram, hasUri ? maybeOps : uriOrOps);
+ if (!result.ok) {
+ return { errors: result.errors };
+ }
+ if (result.diagram !== document.diagram) {
+ editors.applyDocumentEdit(document, 'Apply operations', result.diagram);
+ }
+ return { summary: result.summary, idMap: result.idMap };
+ },
+ ),
+ );
+}
diff --git a/src/ai/editSession.ts b/src/ai/editSession.ts
new file mode 100644
index 0000000..cd91985
--- /dev/null
+++ b/src/ai/editSession.ts
@@ -0,0 +1,92 @@
+/**
+ * The "Edit Diagram with AI" flow, independent of the VS Code UI so it can be unit tested with a
+ * fake provider and fake dialogs: ask for an instruction, prompt the model, validate the returned
+ * operations, preview the summary and apply only after confirmation.
+ */
+import type { Diagram } from '../model/diagram';
+import { applyOperations, ApplyResult } from './operations';
+import { buildEditPrompt, extractOperationsJson } from './prompt';
+import type { CancellationTokenLike, DiagramAIProvider } from './provider';
+
+/** The open document being edited. */
+export interface AIEditTarget {
+ /** The current diagram (read again before applying, in case it changed meanwhile). */
+ readonly diagram: Diagram;
+ readonly selectedNodeIds: readonly string[];
+ /** Applies the new diagram through the document's normal (undoable) edit path. */
+ applyEdit(label: string, diagram: Diagram): void;
+}
+
+/** `apply` keeps positions, `relayout` also runs auto layout; `undefined` means cancelled. */
+export type AIEditChoice = 'apply' | 'relayout' | undefined;
+
+export interface AIEditUI {
+ askInstruction(): Promise<string | undefined>;
+ /** Runs the model request, showing progress; the token is cancelled if the user cancels. */
+ withProgress<T>(task: (token: CancellationTokenLike) => Promise<T>): Promise<T>;
+ confirm(summary: readonly string[]): Promise<AIEditChoice>;
+ showError(message: string): void;
+ showInfo(message: string): void;
+}
+
+export type AIEditOutcome = 'applied' | 'cancelled' | 'failed' | 'noChanges';
+
+export const AI_EDIT_LABEL = 'Edit with AI';
+
+export async function runAIEdit(target: AIEditTarget, provider: DiagramAIProvider, ui: AIEditUI): Promise<AIEditOutcome> {
+ const instruction = (await ui.askInstruction())?.trim();
+ if (!instruction) {
+ return 'cancelled';
+ }
+ const base = target.diagram;
+ const prompt = buildEditPrompt({ diagram: base, selectedNodeIds: target.selectedNodeIds, instruction });
+
+ let response: string;
+ let cancelled = false;
+ try {
+ response = await ui.withProgress(async (token) => {
+ const text = await provider.complete(prompt, token);
+ cancelled = token.isCancellationRequested;
+ return text;
+ });
+ } catch (err) {
+ ui.showError(`Edit with AI failed: ${err instanceof Error ? err.message : String(err)}`);
+ return 'failed';
+ }
+ if (cancelled) {
+ return 'cancelled';
+ }
+
+ const json = extractOperationsJson(response);
+ const preview = applyOperations(base, json);
+ if (!preview.ok) {
+ ui.showError(`The AI response could not be applied, so the diagram was not changed. ${formatErrors(preview.errors)}`);
+ return 'failed';
+ }
+ if (preview.summary.length === 0) {
+ ui.showInfo('The AI suggested no changes.');
+ return 'noChanges';
+ }
+
+ const choice = await ui.confirm(preview.summary);
+ if (!choice) {
+ return 'cancelled';
+ }
+ let result: ApplyResult = preview;
+ const ops = JSON.parse(json) as unknown[];
+ const batch = choice === 'relayout' ? [...ops, { op: 'applyLayout' }] : ops;
+ if (choice === 'relayout' || target.diagram !== base) {
+ result = applyOperations(target.diagram, batch);
+ }
+ if (!result.ok) {
+ ui.showError(`The diagram changed while waiting for the AI and the edit no longer applies. ${formatErrors(result.errors)}`);
+ return 'failed';
+ }
+ target.applyEdit(AI_EDIT_LABEL, result.diagram);
+ return 'applied';
+}
+
+function formatErrors(errors: readonly string[]): string {
+ const shown = errors.slice(0, 5).join(' ');
+ return errors.length > 5 ? `${shown} (and ${errors.length - 5} more)` : shown;
+}
diff --git a/src/ai/operations.ts b/src/ai/operations.ts
new file mode 100644
index 0000000..a49166b
--- /dev/null
+++ b/src/ai/operations.ts
@@ -0,0 +1,597 @@
+/**
+ * Structured edit operations for diagrams.
+ *
+ * AI agents (and scripts) never rewrite a whole diagram: they send a batch of small operations that
+ * is validated and applied here. This module is pure (no `vscode` import) so the same logic can back
+ * the VS Code commands, unit tests and a future MCP server.
+ *
+ * `applyOperations` is atomic: either every operation in the batch applies, or the caller gets the
+ * original (never mutated) diagram back together with the list of problems.
+ */
+import { autoLayout, DEFAULT_LAYOUT_MODE, isLayoutMode, LAYOUT_MODES, LayoutMode, placeUnpositioned } from '../layout';
+import {
+ DEFAULT_NODE_SIZES,
+ Diagram,
+ DiagramEdge,
+ DiagramNode,
+ findEdge,
+ findNode,
+ isNodeType,
+ MIN_NODE_SIZE,
+ NODE_TYPES,
+ NodeType,
+} from '../model/diagram';
+
+/** Version of the operation schema below. Bump it on any incompatible change. */
+export const OPERATIONS_SCHEMA_VERSION = 1;
+
+export const OPERATION_TYPES = [
+ 'addNode',
+ 'removeNode',
+ 'renameNode',
+ 'moveNode',
+ 'addConnector',
+ 'removeConnector',
+ 'updateNodeMetadata',
+ 'updateConnectorMetadata',
+ 'applyLayout',
+] as const;
+
+export type OperationType = (typeof OPERATION_TYPES)[number];
+
+/** Adds a node. `tempId` names it for later operations in the same batch; the saved id is generated. */
+export interface AddNodeOperation {
+ op: 'addNode';
+ tempId: string;
+ label: string;
+ type?: NodeType;
+ /** `x`/`y` must be given together. Without them the node is placed by auto layout. */
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+}
+
+/** Removes a node and every connector attached to it. */
+export interface RemoveNodeOperation {
+ op: 'removeNode';
+ id: string;
+}
+
+export interface RenameNodeOperation {
+ op: 'renameNode';
+ id: string;
+ label: string;
+}
+
+/** Moves a node's top-left corner to `x`/`y`. */
+export interface MoveNodeOperation {
+ op: 'moveNode';
+ id: string;
+ x: number;
+ y: number;
+}
+
+/** Adds a directed connector `from` → `to`. The optional `tempId` names it for later operations. */
+export interface AddConnectorOperation {
+ op: 'addConnector';
+ from: string;
+ to: string;
+ label?: string;
+ tempId?: string;
+}
+
+export interface RemoveConnectorOperation {
+ op: 'removeConnector';
+ id: string;
+}
+
+/** Updates a node's non-label attributes: its shape type and/or size. At least one is required. */
+export interface UpdateNodeMetadataOperation {
+ op: 'updateNodeMetadata';
+ id: string;
+ type?: NodeType;
+ width?: number;
+ height?: number;
+}
+
+/** Updates a connector's attributes; the format only has a label (an empty string removes it). */
+export interface UpdateConnectorMetadataOperation {
+ op: 'updateConnectorMetadata';
+ id: string;
+ label: string;
+}
+
+/** Re-arranges the whole diagram with the built-in auto layout. */
+export interface ApplyLayoutOperation {
+ op: 'applyLayout';
+ mode?: LayoutMode;
+}
+
+export type DiagramOperation =
+ | AddNodeOperation
+ | RemoveNodeOperation
+ | RenameNodeOperation
+ | MoveNodeOperation
+ | AddConnectorOperation
+ | RemoveConnectorOperation
+ | UpdateNodeMetadataOperation
+ | UpdateConnectorMetadataOperation
+ | ApplyLayoutOperation;
+
+// ---------------------------------------------------------------------------
+// Field and operation specs (single source for the validator and the JSON Schema)
+// ---------------------------------------------------------------------------
+
+type Field = 'id' | 'tempId' | 'from' | 'to' | 'label' | 'type' | 'x' | 'y' | 'width' | 'height' | 'mode';
+
+const REF_DESCRIPTION = 'Id of an existing element, or a tempId defined by an earlier operation in the same batch.';
+
+const FIELD_SCHEMAS: Record<Field, Record<string, unknown>> = {
+ id: { type: 'string', minLength: 1, description: REF_DESCRIPTION },
+ tempId: {
+ type: 'string',
+ minLength: 1,
+ description: 'Caller-chosen temporary id, unique in the batch and not used by the diagram.',
+ },
+ from: { type: 'string', minLength: 1, description: `Source node. ${REF_DESCRIPTION}` },
+ to: { type: 'string', minLength: 1, description: `Target node. ${REF_DESCRIPTION}` },
+ label: { type: 'string' },
+ type: { enum: [...NODE_TYPES], description: 'Node shape.' },
+ x: { type: 'number', description: 'Left edge in canvas pixels.' },
+ y: { type: 'number', description: 'Top edge in canvas pixels.' },
+ width: { type: 'number', minimum: MIN_NODE_SIZE },
+ height: { type: 'number', minimum: MIN_NODE_SIZE },
+ mode: { enum: [...LAYOUT_MODES], description: `Layout direction; defaults to "${DEFAULT_LAYOUT_MODE}".` },
+};
+
+interface OperationSpec {
+ description: string;
+ required: Field[];
+ optional: Field[];
+}
+
+const OPERATION_SPECS: Record<OperationType, OperationSpec> = {
+ addNode: {
+ description:
+ 'Add a node. Omit x/y to have it placed automatically. Later operations reference it by tempId.',
+ required: ['tempId', 'label'],
+ optional: ['type', 'x', 'y', 'width', 'height'],
+ },
+ removeNode: { description: 'Remove a node and all connectors attached to it.', required: ['id'], optional: [] },
+ renameNode: { description: "Change a node's label.", required: ['id', 'label'], optional: [] },
+ moveNode: { description: "Move a node's top-left corner.", required: ['id', 'x', 'y'], optional: [] },
+ addConnector: {
+ description: 'Connect two nodes with a directed connector (from -> to).',
+ required: ['from', 'to'],
+ optional: ['label', 'tempId'],
+ },
+ removeConnector: { description: 'Remove a connector.', required: ['id'], optional: [] },
+ updateNodeMetadata: {
+ description: "Change a node's shape type and/or size (at least one of type, width, height).",
+ required: ['id'],
+ optional: ['type', 'width', 'height'],
+ },
+ updateConnectorMetadata: {
+ description: "Change a connector's label (empty string removes it).",
+ required: ['id', 'label'],
+ optional: [],
+ },
+ applyLayout: { description: 'Re-arrange the whole diagram automatically.', required: [], optional: ['mode'] },
+};
+
+function operationSchema(op: OperationType): Record<string, unknown> {
+ const spec = OPERATION_SPECS[op];
+ const properties: Record<string, unknown> = { op: { const: op } };
+ for (const field of [...spec.required, ...spec.optional]) {
+ properties[field] = FIELD_SCHEMAS[field];
+ }
+ const schema: Record<string, unknown> = {
+ type: 'object',
+ description: spec.description,
+ properties,
+ required: ['op', ...spec.required],
+ additionalProperties: false,
+ };
+ if (op === 'addNode') {
+ schema.dependentRequired = { x: ['y'], y: ['x'] };
+ } else if (op === 'updateNodeMetadata') {
+ schema.minProperties = 3;
+ }
+ return schema;
+}
+
+/** JSON Schema (draft 2020-12) for a batch of operations: a JSON array of operation objects. */
+export const OPERATIONS_JSON_SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ $id: `https://github.com/nabledhq/diagrammerext/schemas/diagram-operations.v${OPERATIONS_SCHEMA_VERSION}.json`,
+ title: 'Diagrammer diagram operations',
+ schemaVersion: OPERATIONS_SCHEMA_VERSION,
+ type: 'array',
+ items: { oneOf: OPERATION_TYPES.map(operationSchema) },
+} as const;
+
+// ---------------------------------------------------------------------------
+// Structural validation
+// ---------------------------------------------------------------------------
+
+export type ParseOperationsResult = { ok: true; operations: DiagramOperation[] } | { ok: false; errors: string[] };
+
+function isOperationType(value: unknown): value is OperationType {
+ return typeof value === 'string' && (OPERATION_TYPES as readonly string[]).includes(value);
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function fieldProblem(field: Field, value: unknown): string | undefined {
+ switch (field) {
+ case 'id':
+ case 'tempId':
+ case 'from':
+ case 'to':
+ return typeof value === 'string' && value !== '' ? undefined : 'must be a non-empty string';
+ case 'label':
+ return typeof value === 'string' ? undefined : 'must be a string';
+ case 'type':
+ return isNodeType(value) ? undefined : `must be one of: ${NODE_TYPES.join(', ')}`;
+ case 'x':
+ case 'y':
+ return typeof value === 'number' && Number.isFinite(value) ? undefined : 'must be a finite number';
+ case 'width':
+ case 'height':
+ return typeof value === 'number' && Number.isFinite(value) && value >= MIN_NODE_SIZE
+ ? undefined
+ : `must be a finite number >= ${MIN_NODE_SIZE}`;
+ case 'mode':
+ return isLayoutMode(value) ? undefined : `must be one of: ${LAYOUT_MODES.join(', ')}`;
+ }
+}
+
+/**
+ * Checks that `input` is a well-formed batch: an array (or a JSON string encoding one) of objects
+ * with a known `op`, all required fields, no unknown fields and correctly typed values. References
+ * to diagram elements are checked by `applyOperations`.
+ */
+export function parseOperations(input: unknown): ParseOperationsResult {
+ let raw = input;
+ if (typeof input === 'string') {
+ try {
+ raw = JSON.parse(input);
+ } catch (err) {
+ return { ok: false, errors: [`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`] };
+ }
+ }
+ if (!Array.isArray(raw)) {
+ return { ok: false, errors: ['Operations must be a JSON array.'] };
+ }
+ const errors: string[] = [];
+ raw.forEach((item, index) => {
+ const where = `operations[${index}]`;
+ if (!isRecord(item)) {
+ errors.push(`${where} must be an object.`);
+ return;
+ }
+ if (!isOperationType(item.op)) {
+ errors.push(`${where}: unknown operation ${JSON.stringify(item.op)}; expected one of: ${OPERATION_TYPES.join(', ')}.`);
+ return;
+ }
+ const spec = OPERATION_SPECS[item.op];
+ const allowed = new Set<string>(['op', ...spec.required, ...spec.optional]);
+ for (const key of Object.keys(item)) {
+ if (!allowed.has(key)) {
+ errors.push(`${where} (${item.op}): unknown field "${key}".`);
+ }
+ }
+ for (const field of spec.required) {
+ if (item[field] === undefined) {
+ errors.push(`${where} (${item.op}): missing required field "${field}".`);
+ }
+ }
+ for (const field of [...spec.required, ...spec.optional]) {
+ const problem = item[field] === undefined ? undefined : fieldProblem(field, item[field]);
+ if (problem) {
+ errors.push(`${where} (${item.op}): "${field}" ${problem}.`);
+ }
+ }
+ if (item.op === 'addNode' && (item.x === undefined) !== (item.y === undefined)) {
+ errors.push(`${where} (addNode): "x" and "y" must be given together.`);
+ }
+ if (item.op === 'updateNodeMetadata' && item.type === undefined && item.width === undefined && item.height === undefined) {
+ errors.push(`${where} (updateNodeMetadata): give at least one of "type", "width", "height".`);
+ }
+ });
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, operations: raw as DiagramOperation[] };
+}
+
+// ---------------------------------------------------------------------------
+// Application
+// ---------------------------------------------------------------------------
+
+export interface ApplySuccess {
+ ok: true;
+ diagram: Diagram;
+ /** Human-readable description of the batch, one line per change group. */
+ summary: string[];
+ /** Maps every tempId in the batch to the id it was saved under. */
+ idMap: Record<string, string>;
+}
+
+export interface ApplyFailure {
+ ok: false;
+ /** The input diagram, unchanged. */
+ diagram: Diagram;
+ errors: string[];
+}
+
+export type ApplyResult = ApplySuccess | ApplyFailure;
+
+class OperationError extends Error {}
+
+/** Validates a batch without applying it. Returns the list of problems (empty if it would apply). */
+export function validateOperations(diagram: Diagram, input: unknown): string[] {
+ const result = applyOperations(diagram, input);
+ return result.ok ? [] : result.errors;
+}
+
+/**
+ * Validates and applies a batch of operations in order. Pure and atomic: `diagram` is never
+ * mutated, and if any operation is invalid the result carries every problem found and the original
+ * diagram. Nodes added without a position are placed with auto layout after the batch.
+ */
+export function applyOperations(diagram: Diagram, input: unknown): ApplyResult {
+ const parsed = parseOperations(input);
+ if (!parsed.ok) {
+ return { ok: false, diagram, errors: parsed.errors };
+ }
+ const operations = parsed.operations;
+
+ const reserved = new Set<string>([...diagram.nodes.map((n) => n.id), ...diagram.edges.map((e) => e.id)]);
+ for (const op of operations) {
+ if ((op.op === 'addNode' || op.op === 'addConnector') && op.tempId !== undefined) {
+ reserved.add(op.tempId);
+ }
+ }
+ const freshId = (prefix: string): string => {
+ let n = 1;
+ while (reserved.has(`${prefix}-${n}`)) {
+ n++;
+ }
+ reserved.add(`${prefix}-${n}`);
+ return `${prefix}-${n}`;
+ };
+
+ let current = diagram;
+ const tempIds = new Map<string, string>();
+ const unplaced = new Set<string>();
+ const summary = new SummaryBuilder();
+ const errors: string[] = [];
+
+ const resolve = (ref: string): string => tempIds.get(ref) ?? ref;
+ const nodeRef = (ref: string, field: string): DiagramNode => {
+ const node = findNode(current, resolve(ref));
+ if (!node) {
+ throw new OperationError(`"${field}" refers to missing node "${ref}".`);
+ }
+ return node;
+ };
+ const edgeRef = (ref: string): DiagramEdge => {
+ const edge = findEdge(current, resolve(ref));
+ if (!edge) {
+ throw new OperationError(`"id" refers to missing connector "${ref}".`);
+ }
+ return edge;
+ };
+ const claimTempId = (tempId: string): void => {
+ if (tempIds.has(tempId)) {
+ throw new OperationError(`duplicate tempId "${tempId}".`);
+ }
+ if (diagram.nodes.some((n) => n.id === tempId) || diagram.edges.some((e) => e.id === tempId)) {
+ throw new OperationError(`tempId "${tempId}" duplicates an existing id.`);
+ }
+ };
+ const replaceNode = (id: string, update: Partial<DiagramNode>): void => {
+ current = { ...current, nodes: current.nodes.map((n) => (n.id === id ? { ...n, ...update } : n)) };
+ };
+ const labelOf = (id: string): string => findNode(current, id)?.label ?? id;
+
+ operations.forEach((op, index) => {
+ try {
+ switch (op.op) {
+ case 'addNode': {
+ claimTempId(op.tempId);
+ const type = op.type ?? 'rectangle';
+ const defaults = DEFAULT_NODE_SIZES[type];
+ const node: DiagramNode = {
+ id: freshId('node'),
+ type,
+ x: op.x ?? 0,
+ y: op.y ?? 0,
+ width: op.width ?? defaults.width,
+ height: op.height ?? defaults.height,
+ label: op.label,
+ };
+ tempIds.set(op.tempId, node.id);
+ if (op.x === undefined) {
+ unplaced.add(node.id);
+ }
+ current = { ...current, nodes: [...current.nodes, node] };
+ summary.addedNode(node.label);
+ break;
+ }
+ case 'removeNode': {
+ const node = nodeRef(op.id, 'id');
+ const attached = current.edges.filter((e) => e.from === node.id || e.to === node.id);
+ current = {
+ ...current,
+ nodes: current.nodes.filter((n) => n.id !== node.id),
+ edges: current.edges.filter((e) => e.from !== node.id && e.to !== node.id),
+ };
+ unplaced.delete(node.id);
+ summary.removedNode(node.label, attached.length);
+ break;
+ }
+ case 'renameNode': {
+ const node = nodeRef(op.id, 'id');
+ replaceNode(node.id, { label: op.label });
+ summary.line(`Rename ${quote(node.label)} → ${quote(op.label)}`);
+ break;
+ }
+ case 'moveNode': {
+ const node = nodeRef(op.id, 'id');
+ replaceNode(node.id, { x: op.x, y: op.y });
+ unplaced.delete(node.id);
+ summary.movedNode(node.label);
+ break;
+ }
+ case 'addConnector': {
+ if (op.tempId !== undefined) {
+ claimTempId(op.tempId);
+ }
+ const from = nodeRef(op.from, 'from');
+ const to = nodeRef(op.to, 'to');
+ if (from.id === to.id) {
+ throw new OperationError('a connector cannot connect a node to itself.');
+ }
+ if (current.edges.some((e) => e.from === from.id && e.to === to.id)) {
+ throw new OperationError(`${quote(from.label)} is already connected to ${quote(to.label)}.`);
+ }
+ const edge: DiagramEdge = { id: freshId('edge'), from: from.id, to: to.id };
+ if (op.label) {
+ edge.label = op.label;
+ }
+ if (op.tempId !== undefined) {
+ tempIds.set(op.tempId, edge.id);
+ }
+ current = { ...current, edges: [...current.edges, edge] };
+ summary.line(`Connect ${quote(from.label)} → ${quote(to.label)}${op.label ? ` (${quote(op.label)})` : ''}`);
+ break;
+ }
+ case 'removeConnector': {
+ const edge = edgeRef(op.id);
+ current = { ...current, edges: current.edges.filter((e) => e.id !== edge.id) };
+ summary.removedConnector(`${quote(labelOf(edge.from))} → ${quote(labelOf(edge.to))}`);
+ break;
+ }
+ case 'updateNodeMetadata': {
+ const node = nodeRef(op.id, 'id');
+ const update: Partial<DiagramNode> = {};
+ const changes: string[] = [];
+ if (op.type !== undefined) {
+ update.type = op.type;
+ changes.push(`shape ${op.type}`);
+ }
+ if (op.width !== undefined) {
+ update.width = op.width;
+ changes.push(`width ${op.width}`);
+ }
+ if (op.height !== undefined) {
+ update.height = op.height;
+ changes.push(`height ${op.height}`);
+ }
+ replaceNode(node.id, update);
+ summary.line(`Update ${quote(node.label)}: ${changes.join(', ')}`);
+ break;
+ }
+ case 'updateConnectorMetadata': {
+ const edge = edgeRef(op.id);
+ current = {
+ ...current,
+ edges: current.edges.map((e) => {
+ if (e.id !== edge.id) {
+ return e;
+ }
+ const { label: _old, ...rest } = e;
+ return op.label === '' ? rest : { ...rest, label: op.label };
+ }),
+ };
+ const name = `${quote(labelOf(edge.from))} → ${quote(labelOf(edge.to))}`;
+ summary.line(
+ op.label === '' ? `Remove label of connector ${name}` : `Label connector ${name} ${quote(op.label)}`,
+ );
+ break;
+ }
+ case 'applyLayout': {
+ const mode = op.mode ?? DEFAULT_LAYOUT_MODE;
+ current = autoLayout(current, { mode });
+ unplaced.clear();
+ summary.line(`Re-layout the diagram (${mode})`);
+ break;
+ }
+ }
+ } catch (err) {
+ if (!(err instanceof OperationError)) {
+ throw err;
+ }
+ errors.push(`operations[${index}] (${op.op}): ${err.message}`);
+ }
+ });
+
+ if (errors.length > 0) {
+ return { ok: false, diagram, errors };
+ }
+ if (unplaced.size > 0) {
+ current = placeUnpositioned(current, [...unplaced]);
+ }
+ return { ok: true, diagram: current, summary: summary.build(), idMap: Object.fromEntries(tempIds) };
+}
+
+function quote(label: string): string {
+ return JSON.stringify(label.replace(/\s+/g, ' ').trim());
+}
+
+/** Collects the change summary: bulk operations are counted, others get one line each. */
+class SummaryBuilder {
+ private readonly added: string[] = [];
+ private readonly removed: string[] = [];
+ private attachedRemoved = 0;
+ private readonly removedConnectors: string[] = [];
+ private readonly moved: string[] = [];
+ private readonly lines: string[] = [];
+
+ addedNode(label: string): void {
+ this.added.push(label);
+ }
+
+ removedNode(label: string, attachedConnectors: number): void {
+ this.removed.push(label);
+ this.attachedRemoved += attachedConnectors;
+ }
+
+ removedConnector(name: string): void {
+ this.removedConnectors.push(name);
+ }
+
+ movedNode(label: string): void {
+ this.moved.push(label);
+ }
+
+ line(text: string): void {
+ this.lines.push(text);
+ }
+
+ build(): string[] {
+ const result: string[] = [];
+ const list = (labels: string[]) => labels.map(quote).join(', ');
+ if (this.added.length > 0) {
+ result.push(`Add ${plural(this.added.length, 'node')}: ${list(this.added)}`);
+ }
+ if (this.removed.length > 0) {
+ const attached =
+ this.attachedRemoved > 0 ? ` (and ${plural(this.attachedRemoved, 'attached connector')})` : '';
+ result.push(`Remove ${plural(this.removed.length, 'node')}: ${list(this.removed)}${attached}`);
+ }
+ if (this.removedConnectors.length > 0) {
+ result.push(`Remove ${plural(this.removedConnectors.length, 'connector')}: ${this.removedConnectors.join(', ')}`);
+ }
+ if (this.moved.length > 0) {
+ result.push(`Move ${plural(this.moved.length, 'node')}: ${list(this.moved)}`);
+ }
+ return [...result, ...this.lines];
+ }
+}
+
+function plural(count: number, noun: string): string {
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
+}
diff --git a/src/ai/prompt.ts b/src/ai/prompt.ts
new file mode 100644
index 0000000..d8e34c5
--- /dev/null
+++ b/src/ai/prompt.ts
@@ -0,0 +1,69 @@
+/**
+ * Prompt construction and response parsing for AI diagram edits. Pure: no `vscode` import.
+ */
+import type { Diagram } from '../model/diagram';
+import { OPERATIONS_JSON_SCHEMA, OPERATIONS_SCHEMA_VERSION } from './operations';
+
+export interface EditPromptInput {
+ diagram: Diagram;
+ /** Ids of the nodes selected in the editor (may be empty). */
+ selectedNodeIds: readonly string[];
+ instruction: string;
+}
+
+/** Builds the full prompt sent to the language model for one natural-language instruction. */
+export function buildEditPrompt({ diagram, selectedNodeIds, instruction }: EditPromptInput): string {
+ const current = {
+ nodes: diagram.nodes.map((n) => ({
+ id: n.id,
+ label: n.label,
+ type: n.type,
+ x: n.x,
+ y: n.y,
+ width: n.width,
+ height: n.height,
+ })),
+ connectors: diagram.edges.map((e) => ({ id: e.id, from: e.from, to: e.to, label: e.label ?? '' })),
+ };
+ const selected = selectedNodeIds
+ .map((id) => diagram.nodes.find((n) => n.id === id))
+ .filter((n) => n !== undefined)
+ .map((n) => ({ id: n.id, label: n.label }));
+
+ return [
+ 'You edit diagrams in the Diagrammer VS Code extension. Translate the user instruction into a batch of edit',
+ 'operations against the current diagram. Never rewrite the whole diagram.',
+ '',
+ 'Rules:',
+ '- Reference existing nodes and connectors by their "id".',
+ '- Give every new node a unique "tempId" (for example "new-1") and use it to reference the node in later',
+ ' operations of the same batch. tempIds must not reuse existing ids.',
+ '- Omit x/y for new nodes unless the user asks for a specific position; they are placed automatically.',
+ '- "This", "these", "it" or "the selection" in the instruction usually refer to the selected nodes.',
+ '',
+ '## Current diagram',
+ JSON.stringify(current, null, 2),
+ '',
+ '## Selection (nodes currently selected in the editor)',
+ selected.length > 0 ? JSON.stringify(selected, null, 2) : 'Nothing is selected.',
+ '',
+ `## Operation JSON Schema (schemaVersion ${OPERATIONS_SCHEMA_VERSION})`,
+ JSON.stringify(OPERATIONS_JSON_SCHEMA, null, 2),
+ '',
+ '## Instruction',
+ instruction,
+ '',
+ 'Respond with ONLY a JSON array of operations that matches the schema. No explanations, no prose.',
+ 'Respond with [] if the instruction needs no change.',
+ ].join('\n');
+}
+
+/**
+ * Extracts the JSON text from a model response, tolerating surrounding whitespace and Markdown code
+ * fences. The result still has to be validated with `parseOperations`/`applyOperations`.
+ */
+export function extractOperationsJson(response: string): string {
+ const text = response.trim();
+ const fenced = /```[\w-]*[ \t]*\r?\n?([\s\S]*?)```/.exec(text);
+ return (fenced ? fenced[1] : text).trim();
+}
diff --git a/src/ai/provider.ts b/src/ai/provider.ts
new file mode 100644
index 0000000..73ec113
--- /dev/null
+++ b/src/ai/provider.ts
@@ -0,0 +1,21 @@
+/** Minimal cancellation token shape, compatible with `vscode.CancellationToken`. */
+export interface CancellationTokenLike {
+ readonly isCancellationRequested: boolean;
+}
+
+/**
+ * Something that turns a prompt into a model response. The extension uses the VS Code Language
+ * Model API (`VsCodeLanguageModelProvider`); tests use fakes. Implementations must not need
+ * API keys or vendor SDKs of their own.
+ */
+export interface DiagramAIProvider {
+ complete(prompt: string, token?: CancellationTokenLike): Promise<string>;
+}
+
+/** Thrown by a provider when no language model can be used. Its message is shown to the user. */
+export class NoModelAvailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'NoModelAvailableError';
+ }
+}
diff --git a/src/ai/vscodeLmProvider.ts b/src/ai/vscodeLmProvider.ts
new file mode 100644
index 0000000..fddf586
--- /dev/null
+++ b/src/ai/vscodeLmProvider.ts
@@ -0,0 +1,34 @@
+import * as vscode from 'vscode';
+import { DiagramAIProvider, NoModelAvailableError } from './provider';
+
+/** `DiagramAIProvider` backed by the VS Code Language Model API (`vscode.lm`). */
+export class VsCodeLanguageModelProvider implements DiagramAIProvider {
+ /** The editor passes the `vscode.CancellationToken` of its progress notification. */
+ async complete(prompt: string, token?: vscode.CancellationToken): Promise<string> {
+ const models = await vscode.lm.selectChatModels();
+ const model = models[0];
+ if (!model) {
+ throw new NoModelAvailableError(
+ 'No language model is available. Install and sign in to an extension that provides chat models ' +
+ '(for example GitHub Copilot Chat), then try again.',
+ );
+ }
+ try {
+ const response = await model.sendRequest(
+ [vscode.LanguageModelChatMessage.User(prompt)],
+ { justification: 'Diagrammer turns your instruction into diagram edit operations.' },
+ token,
+ );
+ let text = '';
+ for await (const fragment of response.text) {
+ text += fragment;
+ }
+ return text;
+ } catch (err) {
+ if (err instanceof vscode.LanguageModelError) {
+ throw new Error(`${model.name}: ${err.message}`);
+ }
+ throw err;
+ }
+ }
+}
diff --git a/src/diagramEditor.ts b/src/diagramEditor.ts
index 8c1609a..d76a510 100644
--- a/src/diagramEditor.ts
+++ b/src/diagramEditor.ts
@@ -170,6 +170,8 @@ export class DiagramEditorProvider implements vscode.CustomEditorProvider<Diagra
private readonly webviews = new Map<string, Set<vscode.WebviewPanel>>();
private readonly documents = new Map<string, DiagramDocument>();
+ /** Node ids last reported as selected by each webview. */
+ private readonly selections = new Map<vscode.WebviewPanel, string[]>();
constructor(private readonly extensionUri: vscode.Uri) {}
@@ -206,6 +208,7 @@ export class DiagramEditorProvider implements vscode.CustomEditorProvider<Diagra
this.documents.set(key, document);
panel.onDidDispose(() => {
panels.delete(panel);
+ this.selections.delete(panel);
if (panels.size === 0) {
this.webviews.delete(key);
this.documents.delete(key);
@@ -241,6 +244,9 @@ export class DiagramEditorProvider implements vscode.CustomEditorProvider<Diagra
case 'autoLayout':
this.autoLayout(document, isLayoutMode(message.mode) ? message.mode : DEFAULT_LAYOUT_MODE);
break;
+ case 'selection':
+ this.selections.set(panel, Array.isArray(message.nodeIds) ? message.nodeIds : []);
+ break;
case 'rendered':
this._onDidRender.fire({ uri: document.uri, nodes: message.nodes, edges: message.edges });
break;
@@ -275,11 +281,33 @@ export class DiagramEditorProvider implements vscode.CustomEditorProvider<Diagra
if (next === document.diagram) {
return false;
}
- document.applyEdit('Auto layout', next);
+ this.applyDocumentEdit(document, 'Auto layout', next);
+ return true;
+ }
+
+ /** Returns the open diagram with the given URI, or the one in the active Diagrammer editor. */
+ findDocument(uri?: vscode.Uri): DiagramDocument | undefined {
+ const target = uri ?? activeDiagramUri();
+ return target ? this.documents.get(target.toString()) : undefined;
+ }
+
+ /**
+ * Node ids selected in the document's editor: the active panel's selection, or that of any open
+ * panel if none is active. Ids of nodes that no longer exist are dropped.
+ */
+ selectedNodeIds(document: DiagramDocument): string[] {
+ const panels = [...this.panelsFor(document)];
+ const panel = panels.find((p) => p.active) ?? panels.find((p) => this.selections.has(p));
+ const ids = panel ? (this.selections.get(panel) ?? []) : [];
+ return ids.filter((id) => document.diagram.nodes.some((n) => n.id === id));
+ }
+
+ /** Applies a host-side change as a normal (dirtying, undoable) edit and refreshes every open view. */
+ applyDocumentEdit(document: DiagramDocument, label: string, diagram: Diagram): void {
+ document.applyEdit(label, diagram);
for (const panel of this.panelsFor(document)) {
this.postMessage(panel, { type: 'update', diagram: document.diagram });
}
- return true;
}
saveCustomDocument(document: DiagramDocument, cancellation: vscode.CancellationToken): Thenable<void> {
diff --git a/src/extension.ts b/src/extension.ts
index 6595208..093082f 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,4 +1,6 @@
import * as vscode from 'vscode';
+import { registerAICommands } from './ai/commands';
+import { VsCodeLanguageModelProvider } from './ai/vscodeLmProvider';
import { DiagramEditorProvider, RenderReport } from './diagramEditor';
import { LayoutMode } from './layout';
import { NEW_DIAGRAM_COMMAND, newDiagram } from './newDiagram';
@@ -26,6 +28,7 @@ export function activate(context: vscode.ExtensionContext): DiagrammerApi {
),
);
}
+ registerAICommands(context, provider, new VsCodeLanguageModelProvider());
return { onDidRender: provider.onDidRender };
}
diff --git a/src/protocol.ts b/src/protocol.ts
index cf27971..a609b02 100644
--- a/src/protocol.ts
+++ b/src/protocol.ts
@@ -8,7 +8,9 @@ export type WebviewToHostMessage =
/** Asks the host to auto-layout the diagram (default mode when `mode` is omitted). */
| { type: 'autoLayout'; mode?: LayoutMode }
/** Reported after each full render with the number of node and edge elements drawn. */
- | { type: 'rendered'; nodes: number; edges: number };
+ | { type: 'rendered'; nodes: number; edges: number }
+ /** Sent whenever the selected nodes change (the editor currently selects at most one node). */
+ | { type: 'selection'; nodeIds: string[] };
/** Messages sent from the extension host to the webview. */
export type HostToWebviewMessage =
diff --git a/src/test/integration/suite/extension.test.ts b/src/test/integration/suite/extension.test.ts
index 588ff13..1d3b0d1 100644
--- a/src/test/integration/suite/extension.test.ts
+++ b/src/test/integration/suite/extension.test.ts
@@ -175,6 +175,52 @@ describe('Diagrammer extension', () => {
await vscode.commands.executeCommand('workbench.action.files.revert');
});
+ it('registers the AI editing commands', async () => {
+ const commands = await vscode.commands.getCommands(true);
+ assert.ok(commands.includes('diagrammer.editWithAI'));
+ assert.ok(commands.includes('diagrammer.applyOperations'));
+ });
+
+ it('diagrammer.applyOperations applies valid batches as undoable edits and rejects invalid ones', async () => {
+ const uri = workspaceUri('sample.diagram.json');
+ const original = await readDiagramFile(uri);
+ await vscode.commands.executeCommand('vscode.openWith', uri, VIEW_TYPE);
+ await waitFor('custom editor tab', activeCustomEditorInput);
+
+ const invalid = await vscode.commands.executeCommand<{ errors?: string[] }>('diagrammer.applyOperations', [
+ { op: 'renameNode', id: 'node-2', label: 'Public API' },
+ { op: 'removeNode', id: 'missing' },
+ ]);
+ assert.ok(invalid.errors && invalid.errors.length === 1, JSON.stringify(invalid));
+ assert.strictEqual(activeTab()?.isDirty, false);
+
+ const result = await vscode.commands.executeCommand<{ summary?: string[]; idMap?: Record<string, string> }>(
+ 'diagrammer.applyOperations',
+ uri,
+ [
+ { op: 'renameNode', id: 'node-2', label: 'Public API' },
+ { op: 'addNode', tempId: 'queue', label: 'Queue' },
+ { op: 'addConnector', from: 'node-2', to: 'queue' },
+ ],
+ );
+ assert.deepStrictEqual(result.summary, ['Add 1 node: "Queue"', 'Rename "API" → "Public API"', 'Connect "API" → "Queue"']);
+ assert.strictEqual(activeTab()?.isDirty, true);
+
+ await vscode.commands.executeCommand('undo');
+ await waitFor('undo to clean the document', () => (activeTab()?.isDirty === false ? true : undefined));
+ await vscode.commands.executeCommand('redo');
+ await waitFor('redo to dirty the document', () => (activeTab()?.isDirty ? true : undefined));
+ await vscode.commands.executeCommand('workbench.action.files.save');
+
+ const saved = await readDiagramFile(uri);
+ const queueId = result.idMap?.queue;
+ assert.ok(queueId);
+ assert.strictEqual(saved.nodes.find((n) => n.id === 'node-2')?.label, 'Public API');
+ assert.ok(saved.nodes.some((n) => n.id === queueId && n.label === 'Queue'));
+ assert.ok(saved.edges.some((e) => e.from === 'node-2' && e.to === queueId));
+ assert.strictEqual(saved.edges.length, original.edges.length + 1);
+ });
+
it('opens malformed files without throwing and leaves them untouched', async () => {
const uri = workspaceUri('broken.diagram.json');
const before = await vscode.workspace.fs.readFile(uri);
diff --git a/src/test/unit/aiEdit.test.ts b/src/test/unit/aiEdit.test.ts
new file mode 100644
index 0000000..92559e7
--- /dev/null
+++ b/src/test/unit/aiEdit.test.ts
@@ -0,0 +1,208 @@
+import * as assert from 'assert';
+import { AI_EDIT_LABEL, AIEditChoice, AIEditTarget, AIEditUI, runAIEdit } from '../../ai/editSession';
+import { OPERATIONS_JSON_SCHEMA } from '../../ai/operations';
+import { buildEditPrompt, extractOperationsJson } from '../../ai/prompt';
+import { DiagramAIProvider, NoModelAvailableError } from '../../ai/provider';
+import { Diagram, serializeDiagram } from '../../model/diagram';
+
+const SAMPLE: Diagram = {
+ version: 1,
+ nodes: [
+ { id: 'node-1', type: 'rectangle', x: 40, y: 40, width: 140, height: 70, label: 'Worker' },
+ { id: 'node-2', type: 'ellipse', x: 300, y: 40, width: 140, height: 80, label: 'API' },
+ { id: 'node-3', type: 'rectangle', x: 300, y: 240, width: 140, height: 70, label: 'Queue' },
+ ],
+ edges: [{ id: 'edge-1', from: 'node-2', to: 'node-3', label: 'enqueue' }],
+};
+
+const VALID_OPS = [
+ { op: 'renameNode', id: 'node-2', label: 'Public API' },
+ { op: 'addConnector', from: 'node-1', to: 'node-3' },
+ { op: 'addNode', tempId: 'cache', label: 'Cache' },
+];
+
+/** A fake document that behaves like `DiagramDocument`: edits replace the diagram and are recorded. */
+class FakeDocument implements AIEditTarget {
+ edits: { label: string; diagram: Diagram }[] = [];
+ constructor(
+ public diagram: Diagram,
+ readonly selectedNodeIds: string[] = [],
+ ) {}
+ applyEdit(label: string, diagram: Diagram): void {
+ this.edits.push({ label, diagram });
+ this.diagram = diagram;
+ }
+}
+
+class FakeProvider implements DiagramAIProvider {
+ prompts: string[] = [];
+ constructor(private readonly reply: string | Error) {}
+ async complete(prompt: string): Promise<string> {
+ this.prompts.push(prompt);
+ if (this.reply instanceof Error) {
+ throw this.reply;
+ }
+ return this.reply;
+ }
+}
+
+class FakeUI implements AIEditUI {
+ summaries: (readonly string[])[] = [];
+ errors: string[] = [];
+ infos: string[] = [];
+ constructor(
+ private readonly instruction: string | undefined,
+ private readonly choice: AIEditChoice = undefined,
+ ) {}
+ async askInstruction() {
+ return this.instruction;
+ }
+ withProgress<T>(task: (token: { isCancellationRequested: boolean }) => Promise<T>): Promise<T> {
+ return task({ isCancellationRequested: false });
+ }
+ async confirm(summary: readonly string[]) {
+ this.summaries.push(summary);
+ return this.choice;
+ }
+ showError(message: string) {
+ this.errors.push(message);
+ }
+ showInfo(message: string) {
+ this.infos.push(message);
+ }
+}
+
+describe('AI edit prompt', () => {
+ it('contains the diagram, the labelled selection, the schema and the output instruction', () => {
+ const prompt = buildEditPrompt({ diagram: SAMPLE, selectedNodeIds: ['node-2'], instruction: 'Rename this to Gateway' });
+ for (const node of SAMPLE.nodes) {
+ assert.ok(prompt.includes(`"id": "${node.id}"`), node.id);
+ assert.ok(prompt.includes(`"label": "${node.label}"`), node.label);
+ }
+ assert.ok(prompt.includes('"from": "node-2"') && prompt.includes('"label": "enqueue"'));
+ const selection = prompt.slice(prompt.indexOf('## Selection'), prompt.indexOf('## Operation JSON Schema'));
+ assert.ok(selection.includes('"id": "node-2"') && selection.includes('"label": "API"'), selection);
+ assert.ok(!selection.includes('node-1'));
+ assert.ok(prompt.includes(JSON.stringify(OPERATIONS_JSON_SCHEMA, null, 2)));
+ assert.ok(prompt.includes('Rename this to Gateway'));
+ assert.match(prompt, /Respond with ONLY a JSON array of operations/);
+ });
+
+ it('says when nothing is selected', () => {
+ const prompt = buildEditPrompt({ diagram: SAMPLE, selectedNodeIds: [], instruction: 'x' });
+ assert.ok(prompt.includes('Nothing is selected.'));
+ });
+
+ it('extracts JSON from plain and code-fenced responses', () => {
+ assert.strictEqual(extractOperationsJson(' [1] '), '[1]');
+ assert.strictEqual(extractOperationsJson('```json\n[{"op":"applyLayout"}]\n```'), '[{"op":"applyLayout"}]');
+ assert.strictEqual(extractOperationsJson('Here you go:\n```\n[]\n```\nDone.'), '[]');
+ });
+});
+
+describe('Edit with AI flow (mocked provider)', () => {
+ it('sends the diagram, selected node ids and schema to the provider', async () => {
+ const doc = new FakeDocument(SAMPLE, ['node-1']);
+ const provider = new FakeProvider(JSON.stringify(VALID_OPS));
+ await runAIEdit(doc, provider, new FakeUI('Connect the worker to the queue'));
+ assert.strictEqual(provider.prompts.length, 1);
+ const prompt = provider.prompts[0];
+ assert.ok(prompt.includes('"id": "node-3"'));
+ assert.ok(prompt.includes('## Selection'));
+ assert.match(prompt.slice(prompt.indexOf('## Selection')), /"id": "node-1",\s*"label": "Worker"/);
+ assert.ok(prompt.includes('"schemaVersion": 1'));
+ });
+
+ it('shows a summary and leaves the document unchanged on Cancel', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const before = serializeDiagram(doc.diagram);
+ const ui = new FakeUI('Rename the API', undefined);
+ const outcome = await runAIEdit(doc, new FakeProvider(JSON.stringify(VALID_OPS)), ui);
+ assert.strictEqual(outcome, 'cancelled');
+ assert.deepStrictEqual(ui.summaries, [
+ ['Add 1 node: "Cache"', 'Rename "API" → "Public API"', 'Connect "Worker" → "Queue"'],
+ ]);
+ assert.deepStrictEqual(doc.edits, []);
+ assert.strictEqual(serializeDiagram(doc.diagram), before);
+ });
+
+ it('applies the batch as one edit on Apply, keeping positions', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const ui = new FakeUI('Rename the API', 'apply');
+ const outcome = await runAIEdit(doc, new FakeProvider('```json\n' + JSON.stringify(VALID_OPS) + '\n```'), ui);
+ assert.strictEqual(outcome, 'applied');
+ assert.strictEqual(doc.edits.length, 1);
+ assert.strictEqual(doc.edits[0].label, AI_EDIT_LABEL);
+ assert.strictEqual(doc.diagram.nodes[1].label, 'Public API');
+ assert.deepStrictEqual(doc.diagram.nodes.slice(0, 3).map((n) => [n.x, n.y]), SAMPLE.nodes.map((n) => [n.x, n.y]));
+ assert.ok(doc.diagram.edges.some((e) => e.from === 'node-1' && e.to === 'node-3'));
+ assert.deepStrictEqual(ui.errors, []);
+ });
+
+ it('re-lays out the diagram when asked', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ await runAIEdit(doc, new FakeProvider(JSON.stringify(VALID_OPS)), new FakeUI('x', 'relayout'));
+ assert.strictEqual(doc.edits.length, 1);
+ assert.notDeepStrictEqual(doc.diagram.nodes.slice(0, 3).map((n) => [n.x, n.y]), SAMPLE.nodes.map((n) => [n.x, n.y]));
+ });
+
+ for (const [name, reply] of [
+ ['unparseable output', 'Sure! I renamed the API for you.'],
+ ['a non-array', '{"op":"renameNode","id":"node-2","label":"X"}'],
+ ['an unknown operation', '[{"op":"groupNodes","ids":["node-1"]}]'],
+ ['a missing id', '[{"op":"renameNode","id":"node-2","label":"X"},{"op":"removeNode","id":"node-9"}]'],
+ ] as const) {
+ it(`shows an error and does not modify the document for ${name}`, async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const before = serializeDiagram(doc.diagram);
+ const ui = new FakeUI('do it', 'apply');
+ assert.strictEqual(await runAIEdit(doc, new FakeProvider(reply), ui), 'failed');
+ assert.strictEqual(ui.errors.length, 1);
+ assert.match(ui.errors[0], /could not be applied/);
+ assert.deepStrictEqual(ui.summaries, [], 'no preview for invalid output');
+ assert.deepStrictEqual(doc.edits, []);
+ assert.strictEqual(serializeDiagram(doc.diagram), before);
+ });
+ }
+
+ it('reports provider errors such as a missing model', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const ui = new FakeUI('do it', 'apply');
+ const outcome = await runAIEdit(doc, new FakeProvider(new NoModelAvailableError('No language model is available.')), ui);
+ assert.strictEqual(outcome, 'failed');
+ assert.match(ui.errors[0], /No language model is available/);
+ assert.deepStrictEqual(doc.edits, []);
+ });
+
+ it('does nothing when the instruction is dismissed or empty', async () => {
+ for (const instruction of [undefined, ' ']) {
+ const provider = new FakeProvider('[]');
+ const doc = new FakeDocument(SAMPLE);
+ assert.strictEqual(await runAIEdit(doc, provider, new FakeUI(instruction, 'apply')), 'cancelled');
+ assert.deepStrictEqual(provider.prompts, []);
+ assert.deepStrictEqual(doc.edits, []);
+ }
+ });
+
+ it('tells the user when the model suggests no changes', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const ui = new FakeUI('nothing', 'apply');
+ assert.strictEqual(await runAIEdit(doc, new FakeProvider('[]'), ui), 'noChanges');
+ assert.deepStrictEqual(ui.summaries, []);
+ assert.deepStrictEqual(doc.edits, []);
+ });
+
+ it('re-validates against the current diagram if it changed while waiting', async () => {
+ const doc = new FakeDocument(SAMPLE);
+ const ui = new FakeUI('x', 'apply');
+ ui.confirm = async (summary) => {
+ ui.summaries.push(summary);
+ // The user deleted the API node while the preview was open.
+ doc.diagram = { ...SAMPLE, nodes: SAMPLE.nodes.filter((n) => n.id !== 'node-2'), edges: [] };
+ return 'apply';
+ };
+ assert.strictEqual(await runAIEdit(doc, new FakeProvider(JSON.stringify(VALID_OPS)), ui), 'failed');
+ assert.match(ui.errors[0], /no longer applies/);
+ assert.deepStrictEqual(doc.edits, []);
+ });
+});
diff --git a/src/test/unit/operations.test.ts b/src/test/unit/operations.test.ts
new file mode 100644
index 0000000..de511f6
--- /dev/null
+++ b/src/test/unit/operations.test.ts
@@ -0,0 +1,383 @@
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+ applyOperations,
+ ApplyResult,
+ ApplySuccess,
+ OPERATION_TYPES,
+ OPERATIONS_JSON_SCHEMA,
+ OPERATIONS_SCHEMA_VERSION,
+ parseOperations,
+ validateOperations,
+} from '../../ai/operations';
+import { Diagram, parseDiagram, serializeDiagram } from '../../model/diagram';
+
+function sample(): Diagram {
+ return {
+ version: 1,
+ nodes: [
+ { id: 'client', type: 'rectangle', x: 40, y: 40, width: 140, height: 70, label: 'Client' },
+ { id: 'api', type: 'ellipse', x: 300, y: 40, width: 140, height: 80, label: 'API' },
+ { id: 'db', type: 'rectangle', x: 300, y: 240, width: 140, height: 70, label: 'Database' },
+ ],
+ edges: [
+ { id: 'e1', from: 'client', to: 'api', label: 'HTTP' },
+ { id: 'e2', from: 'api', to: 'db' },
+ ],
+ };
+}
+
+function deepFreeze<T>(value: T): T {
+ if (typeof value === 'object' && value !== null) {
+ Object.values(value).forEach(deepFreeze);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+function ok(result: ApplyResult): ApplySuccess {
+ assert.ok(result.ok, `expected success, got: ${result.ok ? '' : result.errors.join(' | ')}`);
+ return result;
+}
+
+function errorsOf(result: ApplyResult): string[] {
+ assert.ok(!result.ok, 'expected the batch to be rejected');
+ return result.errors;
+}
+
+describe('AI operations', () => {
+ describe('applyOperations', () => {
+ it('addNode creates a node with defaults and places it automatically without overlap', () => {
+ const before = deepFreeze(sample());
+ const result = ok(applyOperations(before, [{ op: 'addNode', tempId: 'cache', label: 'Cache', type: 'diamond' }]));
+ const added = result.diagram.nodes.find((n) => n.id === result.idMap.cache);
+ assert.ok(added);
+ assert.strictEqual(added.id, 'node-1');
+ assert.deepStrictEqual(
+ { type: added.type, label: added.label, width: added.width, height: added.height },
+ { type: 'diamond', label: 'Cache', width: 140, height: 100 },
+ );
+ // Placed below the existing nodes.
+ assert.ok(added.y > 310, `y=${added.y}`);
+ assert.deepStrictEqual(result.summary, ['Add 1 node: "Cache"']);
+ // The result is a valid diagram that round-trips through the file format.
+ assert.deepStrictEqual(parseDiagram(serializeDiagram(result.diagram)), result.diagram);
+ });
+
+ it('addNode keeps explicit positions and sizes', () => {
+ const result = ok(
+ applyOperations(sample(), [{ op: 'addNode', tempId: 'n', label: 'N', x: 10, y: 20, width: 50, height: 30 }]),
+ );
+ const added = result.diagram.nodes[3];
+ assert.deepStrictEqual(
+ { x: added.x, y: added.y, width: added.width, height: added.height, type: added.type },
+ { x: 10, y: 20, width: 50, height: 30, type: 'rectangle' },
+ );
+ assert.deepStrictEqual(result.diagram.nodes.slice(0, 3), sample().nodes, 'existing nodes do not move');
+ });
+
+ it('lets later operations reference tempIds of new nodes and connectors', () => {
+ const result = ok(
+ applyOperations(sample(), [
+ { op: 'addNode', tempId: 'worker', label: 'Worker' },
+ { op: 'addNode', tempId: 'queue', label: 'Queue', type: 'roundedRectangle' },
+ { op: 'addConnector', from: 'worker', to: 'queue', tempId: 'wq' },
+ { op: 'addConnector', from: 'api', to: 'queue', label: 'enqueue' },
+ { op: 'updateConnectorMetadata', id: 'wq', label: 'consume' },
+ { op: 'renameNode', id: 'worker', label: 'Job worker' },
+ ]),
+ );
+ const { worker, queue, wq } = result.idMap;
+ assert.deepStrictEqual(result.idMap, { worker: 'node-1', queue: 'node-2', wq: 'edge-1' });
+ assert.deepStrictEqual(result.diagram.edges.slice(2), [
+ { id: wq, from: worker, to: queue, label: 'consume' },
+ { id: 'edge-2', from: 'api', to: queue, label: 'enqueue' },
+ ]);
+ assert.strictEqual(result.diagram.nodes.find((n) => n.id === worker)?.label, 'Job worker');
+ assert.deepStrictEqual(result.summary, [
+ 'Add 2 nodes: "Worker", "Queue"',
+ 'Connect "Worker" → "Queue"',
+ 'Connect "API" → "Queue" ("enqueue")',
+ 'Label connector "Worker" → "Queue" "consume"',
+ 'Rename "Worker" → "Job worker"',
+ ]);
+ });
+
+ it('generated ids never collide with existing ids or tempIds', () => {
+ const d = sample();
+ d.nodes[0].id = 'node-1';
+ d.edges[0].from = 'node-1';
+ const result = ok(
+ applyOperations(d, [
+ { op: 'addNode', tempId: 'a', label: 'A' },
+ { op: 'addNode', tempId: 'node-2', label: 'B' },
+ ]),
+ );
+ assert.deepStrictEqual(result.idMap, { a: 'node-3', 'node-2': 'node-4' });
+ });
+
+ it('removeNode also removes attached connectors', () => {
+ const before = deepFreeze(sample());
+ const result = ok(applyOperations(before, [{ op: 'removeNode', id: 'api' }]));
+ assert.deepStrictEqual(result.diagram.nodes.map((n) => n.id), ['client', 'db']);
+ assert.deepStrictEqual(result.diagram.edges, []);
+ assert.deepStrictEqual(result.summary, ['Remove 1 node: "API" (and 2 attached connectors)']);
+ });
+
+ it('renameNode changes only the label', () => {
+ const result = ok(applyOperations(sample(), [{ op: 'renameNode', id: 'api', label: 'Public API' }]));
+ assert.deepStrictEqual(result.diagram.nodes[1], { ...sample().nodes[1], label: 'Public API' });
+ assert.deepStrictEqual(result.summary, ['Rename "API" → "Public API"']);
+ });
+
+ it('moveNode sets the position', () => {
+ const result = ok(applyOperations(sample(), [{ op: 'moveNode', id: 'db', x: 600, y: 10 }]));
+ assert.deepStrictEqual(result.diagram.nodes[2], { ...sample().nodes[2], x: 600, y: 10 });
+ assert.deepStrictEqual(result.summary, ['Move 1 node: "Database"']);
+ });
+
+ it('addConnector connects existing nodes', () => {
+ const result = ok(applyOperations(sample(), [{ op: 'addConnector', from: 'client', to: 'db' }]));
+ assert.deepStrictEqual(result.diagram.edges[2], { id: 'edge-1', from: 'client', to: 'db' });
+ assert.deepStrictEqual(result.summary, ['Connect "Client" → "Database"']);
+ });
+
+ it('removeConnector removes only that connector', () => {
+ const result = ok(applyOperations(sample(), [{ op: 'removeConnector', id: 'e1' }]));
+ assert.deepStrictEqual(result.diagram.edges, [sample().edges[1]]);
+ assert.deepStrictEqual(result.diagram.nodes, sample().nodes);
+ assert.deepStrictEqual(result.summary, ['Remove 1 connector: "Client" → "API"']);
+ });
+
+ it('updateNodeMetadata changes type and size', () => {
+ const result = ok(
+ applyOperations(sample(), [{ op: 'updateNodeMetadata', id: 'db', type: 'ellipse', width: 200 }]),
+ );
+ assert.deepStrictEqual(result.diagram.nodes[2], { ...sample().nodes[2], type: 'ellipse', width: 200 });
+ assert.deepStrictEqual(result.summary, ['Update "Database": shape ellipse, width 200']);
+ });
+
+ it('updateConnectorMetadata sets and clears connector labels', () => {
+ const result = ok(
+ applyOperations(sample(), [
+ { op: 'updateConnectorMetadata', id: 'e1', label: '' },
+ { op: 'updateConnectorMetadata', id: 'e2', label: 'SQL' },
+ ]),
+ );
+ assert.deepStrictEqual(result.diagram.edges, [
+ { id: 'e1', from: 'client', to: 'api' },
+ { id: 'e2', from: 'api', to: 'db', label: 'SQL' },
+ ]);
+ });
+
+ it('applyLayout delegates to auto layout', () => {
+ const result = ok(applyOperations(sample(), [{ op: 'applyLayout', mode: 'left-to-right' }]));
+ const [client, api, db] = result.diagram.nodes;
+ assert.ok(client.x < api.x && api.x < db.x, 'connectors point right');
+ assert.deepStrictEqual(result.summary, ['Re-layout the diagram (left-to-right)']);
+ });
+
+ it('accepts a JSON string and an empty batch', () => {
+ const result = ok(applyOperations(sample(), '[{"op":"renameNode","id":"db","label":"DB"}]'));
+ assert.strictEqual(result.diagram.nodes[2].label, 'DB');
+ const empty = ok(applyOperations(sample(), []));
+ assert.deepStrictEqual(empty.summary, []);
+ assert.deepStrictEqual(empty.diagram, sample());
+ });
+
+ it('is atomic: one invalid operation leaves the diagram byte-identical and unmutated', () => {
+ const before = sample();
+ const serialized = serializeDiagram(before);
+ const snapshot = JSON.parse(JSON.stringify(before));
+ const result = applyOperations(before, [
+ { op: 'addNode', tempId: 'x', label: 'X' },
+ { op: 'removeNode', id: 'api' },
+ { op: 'renameNode', id: 'client', label: 'Browser' },
+ { op: 'moveNode', id: 'db', x: 1, y: 1 },
+ { op: 'addConnector', from: 'x', to: 'nope' },
+ ]);
+ assert.ok(!result.ok);
+ assert.strictEqual(result.diagram, before);
+ assert.strictEqual(serializeDiagram(result.diagram), serialized);
+ assert.strictEqual(serializeDiagram(before), serialized);
+ assert.deepStrictEqual(before, snapshot);
+ assert.deepStrictEqual(result.errors, ['operations[4] (addConnector): "to" refers to missing node "nope".']);
+ });
+
+ it('never mutates a frozen input diagram', () => {
+ const before = deepFreeze(sample());
+ assert.doesNotThrow(() =>
+ applyOperations(before, [
+ { op: 'addNode', tempId: 'x', label: 'X' },
+ { op: 'updateNodeMetadata', id: 'api', height: 99 },
+ { op: 'updateConnectorMetadata', id: 'e1', label: '' },
+ { op: 'removeConnector', id: 'e2' },
+ { op: 'applyLayout' },
+ ]),
+ );
+ });
+ });
+
+ describe('validation', () => {
+ it('rejects non-array and unparseable input', () => {
+ assert.deepStrictEqual(errorsOf(applyOperations(sample(), { op: 'removeNode', id: 'api' })), [
+ 'Operations must be a JSON array.',
+ ]);
+ assert.match(errorsOf(applyOperations(sample(), '[{"op": "removeNode",'))[0], /^Invalid JSON/);
+ assert.match(errorsOf(applyOperations(sample(), 'not json'))[0], /^Invalid JSON/);
+ assert.deepStrictEqual(errorsOf(applyOperations(sample(), undefined)), ['Operations must be a JSON array.']);
+ });
+
+ it('rejects unknown operation types and non-object items', () => {
+ const errors = errorsOf(applyOperations(sample(), [{ op: 'groupNodes', ids: ['api'] }, 'removeNode', { id: 'x' }]));
+ assert.strictEqual(errors.length, 3);
+ assert.match(errors[0], /operations\[0\]: unknown operation "groupNodes"/);
+ assert.match(errors[1], /operations\[1\] must be an object/);
+ assert.match(errors[2], /operations\[2\]: unknown operation undefined/);
+ });
+
+ it('rejects malformed fields', () => {
+ const errors = errorsOf(
+ applyOperations(sample(), [
+ { op: 'addNode', label: 'No temp id' },
+ { op: 'addNode', tempId: 't', label: 'X', type: 'hexagon' },
+ { op: 'addNode', tempId: 'u', label: 'X', x: 10 },
+ { op: 'moveNode', id: 'api', x: '10', y: Infinity },
+ { op: 'renameNode', id: 'api', label: 'A', colour: 'red' },
+ { op: 'updateNodeMetadata', id: 'api' },
+ { op: 'updateNodeMetadata', id: 'api', width: 2 },
+ { op: 'applyLayout', mode: 'radial' },
+ { op: 'removeNode', id: '' },
+ ]),
+ );
+ const expected = [
+ /operations\[0\] \(addNode\): missing required field "tempId"/,
+ /operations\[1\] \(addNode\): "type" must be one of/,
+ /operations\[2\] \(addNode\): "x" and "y" must be given together/,
+ /operations\[3\] \(moveNode\): "x" must be a finite number/,
+ /operations\[3\] \(moveNode\): "y" must be a finite number/,
+ /operations\[4\] \(renameNode\): unknown field "colour"/,
+ /operations\[5\] \(updateNodeMetadata\): give at least one of/,
+ /operations\[6\] \(updateNodeMetadata\): "width" must be a finite number >= 10/,
+ /operations\[7\] \(applyLayout\): "mode" must be one of/,
+ /operations\[8\] \(removeNode\): "id" must be a non-empty string/,
+ ];
+ assert.strictEqual(errors.length, expected.length, errors.join('\n'));
+ expected.forEach((pattern, i) => assert.match(errors[i], pattern));
+ });
+
+ it('rejects references to missing ids, including elements removed earlier in the batch', () => {
+ const errors = errorsOf(
+ applyOperations(sample(), [
+ { op: 'renameNode', id: 'ghost', label: 'X' },
+ { op: 'removeConnector', id: 'api' },
+ { op: 'removeNode', id: 'db' },
+ { op: 'moveNode', id: 'db', x: 0, y: 0 },
+ { op: 'updateConnectorMetadata', id: 'e2', label: 'gone with db' },
+ { op: 'addConnector', from: 'later', to: 'api' },
+ { op: 'addNode', tempId: 'later', label: 'Later' },
+ ]),
+ );
+ assert.deepStrictEqual(errors, [
+ 'operations[0] (renameNode): "id" refers to missing node "ghost".',
+ 'operations[1] (removeConnector): "id" refers to missing connector "api".',
+ 'operations[3] (moveNode): "id" refers to missing node "db".',
+ 'operations[4] (updateConnectorMetadata): "id" refers to missing connector "e2".',
+ 'operations[5] (addConnector): "from" refers to missing node "later".',
+ ]);
+ });
+
+ it('rejects duplicate tempIds and tempIds that reuse existing ids', () => {
+ const errors = errorsOf(
+ applyOperations(sample(), [
+ { op: 'addNode', tempId: 'n', label: 'A' },
+ { op: 'addNode', tempId: 'n', label: 'B' },
+ { op: 'addNode', tempId: 'api', label: 'C' },
+ { op: 'addConnector', from: 'client', to: 'db', tempId: 'e1' },
+ ]),
+ );
+ assert.deepStrictEqual(errors, [
+ 'operations[1] (addNode): duplicate tempId "n".',
+ 'operations[2] (addNode): tempId "api" duplicates an existing id.',
+ 'operations[3] (addConnector): tempId "e1" duplicates an existing id.',
+ ]);
+ });
+
+ it('rejects self-loops and duplicate connectors', () => {
+ const errors = errorsOf(
+ applyOperations(sample(), [
+ { op: 'addConnector', from: 'api', to: 'api' },
+ { op: 'addConnector', from: 'client', to: 'api' },
+ ]),
+ );
+ assert.match(errors[0], /cannot connect a node to itself/);
+ assert.match(errors[1], /"Client" is already connected to "API"/);
+ });
+
+ it('validateOperations reports problems without applying', () => {
+ assert.deepStrictEqual(validateOperations(sample(), [{ op: 'removeNode', id: 'api' }]), []);
+ assert.strictEqual(validateOperations(sample(), [{ op: 'removeNode', id: 'nope' }]).length, 1);
+ const ops = parseOperations([{ op: 'removeNode', id: 'api' }]);
+ assert.ok(ops.ok);
+ });
+ });
+
+ describe('JSON Schema', () => {
+ it('declares its schema version', () => {
+ assert.strictEqual(OPERATIONS_JSON_SCHEMA.schemaVersion, OPERATIONS_SCHEMA_VERSION);
+ assert.strictEqual(OPERATIONS_JSON_SCHEMA.type, 'array');
+ });
+
+ it('has one closed object schema per operation type, matching the validator', () => {
+ const variants = OPERATIONS_JSON_SCHEMA.items.oneOf as Record<string, unknown>[];
+ assert.deepStrictEqual(
+ variants.map((v) => (v.properties as Record<string, { const: string }>).op.const),
+ [...OPERATION_TYPES],
+ );
+ for (const variant of variants) {
+ assert.strictEqual(variant.additionalProperties, false);
+ const op = (variant.properties as Record<string, { const: string }>).op.const;
+ const required = (variant.required as string[]).filter((f) => f !== 'op');
+ // Leaving out any required field is rejected by the validator too.
+ for (const field of required) {
+ const item: Record<string, unknown> = { op };
+ for (const f of required) {
+ if (f !== field) {
+ item[f] = f === 'x' || f === 'y' ? 1 : 'v';
+ }
+ }
+ const parsed = parseOperations([item]);
+ assert.ok(!parsed.ok && parsed.errors.some((e) => e.includes(`"${field}"`)), `${op}.${field}`);
+ }
+ }
+ });
+ });
+
+ describe('docs/operations.md', () => {
+ const docs = fs.readFileSync(path.resolve(__dirname, '../../../docs/operations.md'), 'utf8');
+
+ it('documents every operation type and the schema version', () => {
+ for (const op of OPERATION_TYPES) {
+ assert.ok(docs.includes(`| \`${op}\` |`), op);
+ }
+ assert.ok(docs.includes(`"schemaVersion": ${OPERATIONS_SCHEMA_VERSION}`));
+ });
+
+ it('has an example batch that applies and produces the documented summary', () => {
+ const blocks = [...docs.matchAll(/```(\w*)\n([\s\S]*?)```/g)];
+ const example = blocks.find((b) => b[1] === 'json')?.[2];
+ const summary = blocks.find((b) => b[1] === '')?.[2];
+ assert.ok(example && summary);
+ const d: Diagram = {
+ version: 1,
+ nodes: [
+ { id: 'api', type: 'rectangle', x: 0, y: 0, width: 140, height: 70, label: 'API' },
+ { id: 'db', type: 'rectangle', x: 0, y: 200, width: 140, height: 70, label: 'Database' },
+ ],
+ edges: [],
+ };
+ assert.deepStrictEqual(ok(applyOperations(d, example)).summary, summary.trim().split('\n'));
+ });
+ });
+});
diff --git a/src/test/unit/webview.test.ts b/src/test/unit/webview.test.ts
index 11ef9fb..6d481f9 100644
--- a/src/test/unit/webview.test.ts
+++ b/src/test/unit/webview.test.ts
@@ -174,10 +174,33 @@ describe('webview canvas', () => {
const count = h.sent.length;
h.pointer('pointerdown', h.nodeEl('node-1').querySelector('.shape') as Element, 50, 25);
h.pointer('pointerup', h.window, 50, 25);
- assert.strictEqual(h.sent.length, count);
+ // Only the selection change is reported.
+ assert.deepStrictEqual(h.sent.slice(count), [{ type: 'selection', nodeIds: ['node-1'] }]);
assert.ok(h.nodeEl('node-1').classList.contains('selected'));
});
+ it('reports selection changes to the host', () => {
+ const h = setup();
+ const selections = () => h.sent.filter((m) => m.type === 'selection');
+ assert.deepStrictEqual(selections(), [], 'an empty initial selection is not reported');
+ h.pointer('pointerdown', h.nodeEl('node-2').querySelector('.shape') as Element, 350, 25);
+ h.pointer('pointerup', h.window, 350, 25);
+ h.pointer('pointerdown', h.nodeEl('node-2').querySelector('.shape') as Element, 350, 25);
+ h.pointer('pointerup', h.window, 350, 25);
+ assert.deepStrictEqual(selections(), [{ type: 'selection', nodeIds: ['node-2'] }], 'unchanged selection is not resent');
+ // Selecting a connector means no node is selected.
+ h.pointer('pointerdown', h.edgeLine('edge-1'), 200, 25);
+ assert.deepStrictEqual(selections()[selections().length - 1], { type: 'selection', nodeIds: [] });
+ h.pointer('pointerdown', h.nodeEl('node-3').querySelector('.shape') as Element, 50, 325);
+ h.pointer('pointerup', h.window, 50, 325);
+ // The host removes the selected node (e.g. an AI edit): the selection is cleared.
+ h.send({ type: 'update', diagram: { ...SAMPLE, nodes: SAMPLE.nodes.slice(0, 2), edges: SAMPLE.edges.slice(0, 1) } });
+ assert.deepStrictEqual(selections().slice(-2), [
+ { type: 'selection', nodeIds: ['node-3'] },
+ { type: 'selection', nodeIds: [] },
+ ]);
+ });
+
it('creates a connector by dragging from a handle to another node', () => {
const h = setup();
const handle = h.nodeEl('node-1').querySelector('.handle') as Element;
@@ -207,7 +230,7 @@ describe('webview canvas', () => {
h.pointer('pointermove', h.window, 700, 700);
h.pointer('pointerup', h.window, 700, 700);
assert.strictEqual(h.sent.filter((m) => m.type === 'edit').length, 0);
- assert.strictEqual(h.sent.length, count);
+ assert.deepStrictEqual(h.sent.slice(count), [{ type: 'selection', nodeIds: ['node-1'] }]);
});
it('deletes the selected node together with its connectors', () => {
diff --git a/src/webview/main.ts b/src/webview/main.ts
index 57a18f8..0e3ee44 100644
--- a/src/webview/main.ts
+++ b/src/webview/main.ts
@@ -55,6 +55,8 @@ const errorBox = document.getElementById('error') as HTMLDivElement;
let diagram: Diagram | undefined;
let selection: Selection;
+/** Node ids last sent to the host in a `selection` message. */
+let reportedSelection: string[] = [];
let interaction: Interaction = { kind: 'none' };
let labelEditor: HTMLTextAreaElement | undefined;
@@ -286,6 +288,7 @@ function render(): void {
}
updateCanvasSize();
+ reportSelection();
}
function shapeElement(node: DiagramNode): SVGElement {
@@ -525,6 +528,16 @@ function updateSelectionClasses(): void {
const kind = el.classList.contains('node') ? 'node' : 'edge';
el.classList.toggle('selected', selection?.kind === kind && selection.id === el.getAttribute('data-id'));
}
+ reportSelection();
+}
+
+/** Tells the host which nodes are selected (used as context for AI edits). Only sent on change. */
+function reportSelection(): void {
+ const nodeIds = selection?.kind === 'node' && diagram && findNode(diagram, selection.id) ? [selection.id] : [];
+ if (nodeIds.length !== reportedSelection.length || nodeIds.some((id, i) => id !== reportedSelection[i])) {
+ reportedSelection = nodeIds;
+ vscode.postMessage({ type: 'selection', nodeIds });
+ }
}
function onPointerMove(e: PointerEvent): void {
This is a thorough, well-structured implementation. The operation module has no `vscode` import, is atomic and is validated, with the JSON Schema generated from the same field table as the validator. The interactive flow is cleanly separated from the VS Code UI, uses a modal preview with Keep positions and Re-layout, and reports selection from the webview. Unit tests are strong and map closely to every acceptance criterion. However, no CI ran and the integration test covering real persistence, undo and save was never executed, and the PR raises the minimum VS Code version to 1.90, so backers should confirm a green CI run before accepting.
Acceptance criteria · 10 of 11 met
- YESUnit tests cover applyOperations for each implemented operation type, including removing a node with attached connectors and using tempIdsoperations.test.ts has a dedicated test for each of the 9 ops, including 'removeNode also removes attached connectors' and 'lets later operations reference tempIds of new nodes and connectors'.
- YESA batch with one invalid operation leaves the diagram byte-identical (serialized form equal) and unmutatedThe test 'is atomic: one invalid operation leaves the diagram byte-identical and unmutated' compares serializeDiagram output and a deep snapshot, and a frozen-input test checks the input is never mutated.
- YESValidator rejects unknown types, missing IDs, duplicate IDs and malformed JSONThe validation tests cover unknown op 'groupNodes', missing ids (including elements removed earlier in the batch), duplicate and existing-id tempIds, invalid JSON strings, non-arrays and malformed fields.
- YESMocked-provider test verifies the prompt contains the diagram, selected node IDs and schemaaiEdit.test.ts 'sends the diagram, selected node ids and schema to the provider' and the buildEditPrompt test check node ids, the labelled selection section and the full schema JSON.
- YESWith a mocked provider returning valid operations, the command shows a summary and Cancel leaves the document unchanged'shows a summary and leaves the document unchanged on Cancel' asserts the summary lines, no edits and an identical serialization; it runs `runAIEdit`, not the registered command.
- YESWith the same mocked provider, Apply updates the document'applies the batch as one edit on Apply, keeping positions' asserts one edit labelled 'Edit with AI' and the renamed node and new connector.
- YESUnparseable or invalid LLM output shows an error and does not modify the documentA parametrized test covers prose output, a non-array, an unknown op and a missing id, each asserting an error, no preview and an unchanged serialization.
- YESdiagrammer.applyOperations applies valid operations and returns errors for invalid ones, without any LLM callcommands.ts calls `applyOperations` directly and returns `{summary,idMap}` or `{errors}`; the integration test covers both paths but was not run.
- YESdocs/operations.md exists and matches the implemented schemadocs/operations.md documents all ops, the id/tempId rules, the schema version, an example, the command call and the groupNodes omission, and unit tests check that every op is listed and that the example batch produces the documented summary.
- YESNo AI vendor SDK added; provider sits behind DiagramAIProviderpackage.json adds no dependencies, and VsCodeLanguageModelProvider implements DiagramAIProvider using only `vscode.lm`.
- UNCLEARExisting build, lint and tests passThe builder reports that unit tests, lint, type-check and build pass, but no CI ran and the integration suite was not executed.
- No CI checks ran on this commit, and the builder says the new VS Code integration test (the `applyOperations` round-trip with undo, redo and save) was never run because VS Code could not be downloaded. The persistence and undo path through `applyDocumentEdit` is therefore only exercised by unit-level fakes so far.
- The minimum VS Code version moves from 1.85 to 1.90 (`engines.vscode`, `@types/vscode`). This is needed for the stable `vscode.lm` API, but it drops support for older VS Code releases for all users, not just AI users.
- The interactive flow is tested through `runAIEdit` with a fake UI and fake document. The real `vscodeUI` wiring in `commands.ts` (modal buttons, dismiss mapped to cancel, progress cancellation) and the `editWithAI` command handler have no automated coverage.
- The host stores `message.nodeIds` from the webview whenever it is an array, without checking the elements are strings. This is low risk because `selectedNodeIds` later filters to existing node ids.
- The builder summary is cut off mid-sentence. The diff itself looks complete and was judged as shown.
CI details
No CI checks ran on this commit.
Both keys are needed: a majority of backers to accept and the maintainer to merge. The window closes in 6 days. Below quorum, the automated verdict decides for the backers. Rebuilds used: 0 of 2.
Automated review cost $0.22, counted as builder cost.
No comments yet.