From c1f7b2c56059f995a760da1ce7aa3cbc54f25bd5 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 3 Jun 2026 02:53:16 +0800 Subject: [PATCH 1/6] Add resolveCodefixes API for dynamic code action expansion Extends the CodeFix interface with an optional resolveCodefixes() method that allows a single codefix to expand into multiple labeled options at Ctrl+. time. This enables AI-powered codefixes where the labels are determined asynchronously (e.g., via vscode.lm through the existing custom/chatCompletion LSP bridge). Changes: - types.ts: Add resolveCodefixes to CodeFix interface - serverlib.ts: Modify getCodeActions to resolve and cache dynamic codefixes - Promise-based cache prevents duplicate AI calls from concurrent requests - Separate resolvedCodefixMap for resolveCodeAction lookup - Both caches cleared on recompilation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/core/types.ts | 7 +++ packages/compiler/src/server/serverlib.ts | 52 +++++++++++++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..4ff1bed8586 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2184,6 +2184,13 @@ export interface CodeFix { readonly id: string; readonly label: string; readonly fix: (fixContext: CodeFixContext) => CodeFixEdit | CodeFixEdit[] | Promise | void; + /** + * Optional async resolver that expands this codefix into multiple labeled codefixes. + * Called when the user triggers the code action menu (Ctrl+.). + * Each returned item becomes a separate entry in the quickfix menu with its own label. + * If not provided, the single `label` and `fix` are used as-is. + */ + readonly resolveCodefixes?: () => Promise; } export interface FilePos { diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 964cca1f927..da2ce332392 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -75,6 +75,7 @@ import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file. import { createRemoveUnusedSuppressionCodeFix } from "../core/suppression-tracking.js"; import { AugmentDecoratorStatementNode, + CodeFix, CodeFixEdit, CompilerHost, DecoratorDeclarationStatementNode, @@ -178,6 +179,8 @@ export function createServer( }); let currentDiagnosticIndex = new Map(); let diagnosticIdCounter = 0; + const resolvedCodefixCache = new Map>(); + const resolvedCodefixMap = new Map(); let workspaceFolders: ServerWorkspaceFolder[] = []; let isInitialized = false; @@ -880,6 +883,8 @@ export function createServer( // Atomically swap the diagnostic index so that in-flight code action resolves // referencing old diagnostic IDs can still find their entries until new diagnostics are sent. currentDiagnosticIndex = newDiagnosticIndex; + resolvedCodefixCache.clear(); + resolvedCodefixMap.clear(); for (const [document, diagnostics] of diagnosticMap) { sendDiagnostics(document, diagnostics); @@ -1396,13 +1401,40 @@ export function createServer( if (tspDiag === undefined || tspDiag.codefixes === undefined) continue; for (const fix of tspDiag.codefixes ?? []) { - const codeAction: CodeAction = { - title: fix.label, - kind: CodeActionKind.QuickFix, - diagnostics: [vsDiag], - data: { diagId: vsDiag.data?.id, fixId: fix.id }, - }; - actions.push(codeAction); + if (fix.resolveCodefixes) { + const cacheKey = `${vsDiag.data?.id}:${fix.id}`; + if (!resolvedCodefixCache.has(cacheKey)) { + resolvedCodefixCache.set(cacheKey, fix.resolveCodefixes().catch(() => [])); + } + + try { + const resolved = await resolvedCodefixCache.get(cacheKey)!; + for (const resolvedFix of resolved) { + // Store in map for resolveCodeAction lookup (not in the diagnostic array) + resolvedCodefixMap.set(resolvedFix.id, resolvedFix); + actions.push({ + title: resolvedFix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: resolvedFix.id }, + }); + } + } catch { + actions.push({ + title: fix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: fix.id }, + }); + } + } else { + actions.push({ + title: fix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: fix.id }, + }); + } } } @@ -1412,8 +1444,10 @@ export function createServer( async function resolveCodeAction(codeAction: CodeAction): Promise { const { diagId, fixId } = codeAction.data ?? {}; if (diagId !== undefined && fixId) { - const diag = currentDiagnosticIndex.get(diagId); - const codeFix = diag?.codefixes?.find((x) => x.id === fixId); + // Check resolved codefixes first (from resolveCodefixes), then diagnostic codefixes + const codeFix = + resolvedCodefixMap.get(fixId) ?? + currentDiagnosticIndex.get(diagId)?.codefixes?.find((x) => x.id === fixId); if (codeFix) { const edits = await resolveCodeFix(codeFix); codeAction.edit = { documentChanges: convertCodeFixEdits(edits) }; From 2f4234ba0d994ea829ec43b00688717fd0fc99c4 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 3 Jun 2026 04:29:22 +0800 Subject: [PATCH 2/6] Add inline comments for AI suggestion cache lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/server/serverlib.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index da2ce332392..3fe82cf0fac 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -179,8 +179,8 @@ export function createServer( }); let currentDiagnosticIndex = new Map(); let diagnosticIdCounter = 0; - const resolvedCodefixCache = new Map>(); - const resolvedCodefixMap = new Map(); + const resolvedCodefixCache = new Map>(); // AI suggestion result cache - declared + const resolvedCodefixMap = new Map(); // resolved codefix lookup for resolveCodeAction let workspaceFolders: ServerWorkspaceFolder[] = []; let isInitialized = false; @@ -883,7 +883,7 @@ export function createServer( // Atomically swap the diagnostic index so that in-flight code action resolves // referencing old diagnostic IDs can still find their entries until new diagnostics are sent. currentDiagnosticIndex = newDiagnosticIndex; - resolvedCodefixCache.clear(); + resolvedCodefixCache.clear(); // AI suggestion result cache - cleared on recompilation resolvedCodefixMap.clear(); for (const [document, diagnostics] of diagnosticMap) { @@ -1404,6 +1404,7 @@ export function createServer( if (fix.resolveCodefixes) { const cacheKey = `${vsDiag.data?.id}:${fix.id}`; if (!resolvedCodefixCache.has(cacheKey)) { + // AI suggestion result cache - checked and populated (Promise cached immediately to prevent concurrent duplicates) resolvedCodefixCache.set(cacheKey, fix.resolveCodefixes().catch(() => [])); } From 38818d5989c884fdf7b7dd0afcf0c03a61c75901 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 8 Jul 2026 03:14:01 +0800 Subject: [PATCH 3/6] Add server test for dynamic codefix actions Verify resolveCodefixes expands one diagnostic fix into multiple code actions and resolves the selected dynamic fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../compiler/test/server/code-action.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/compiler/test/server/code-action.test.ts diff --git a/packages/compiler/test/server/code-action.test.ts b/packages/compiler/test/server/code-action.test.ts new file mode 100644 index 00000000000..6eb3f23880b --- /dev/null +++ b/packages/compiler/test/server/code-action.test.ts @@ -0,0 +1,109 @@ +import { deepStrictEqual, strictEqual } from "assert"; +import { it } from "vitest"; +import { CodeActionKind, Range } from "vscode-languageserver"; +import { createLinterRule } from "../../src/core/library.js"; +import { defineLinter } from "../../src/core/linter.js"; +import { createTestServerHost } from "../../src/testing/test-server-host.js"; + +it("expands dynamic codefixes into separate code actions", async () => { + const host = await createTestServerHost(); + let resolveCalls = 0; + let appliedFix = ""; + + const dynamicRule = createLinterRule({ + name: "dynamic-codefix", + description: "Test dynamic codefix expansion", + severity: "warning", + messages: { + default: "Dynamic codefix test.", + }, + create(context) { + return { + model: (target) => { + context.reportDiagnostic({ + target, + codefixes: [ + { + id: "dynamic-root", + label: "Generate dynamic fixes", + fix: () => undefined, + resolveCodefixes: async () => { + resolveCalls++; + return [ + { + id: "dynamic-first", + label: "Use first suggestion", + fix: () => { + appliedFix = "first"; + }, + }, + { + id: "dynamic-second", + label: "Use second suggestion", + fix: () => { + appliedFix = "second"; + }, + }, + ]; + }, + }, + ], + }); + }, + }; + }, + }); + + host.addTypeSpecFile( + "./node_modules/@typespec/dynamic-codefix-linter/package.json", + JSON.stringify({ + name: "@typespec/dynamic-codefix-linter", + version: "0.0.1", + main: "lib/index.js", + }), + ); + host.addJsFile("./node_modules/@typespec/dynamic-codefix-linter/lib/index.js", { + $linter: defineLinter({ + rules: [dynamicRule], + ruleSets: { + recommended: { + enable: { + "@typespec/dynamic-codefix-linter/dynamic-codefix": true, + }, + }, + }, + }), + }); + + host.addOrUpdateDocument( + "./tspconfig.yaml", + [ + "linter:", + " extends:", + ' - "@typespec/dynamic-codefix-linter/recommended"', + ].join("\n"), + ); + const document = host.addOrUpdateDocument("./main.tsp", "model Test {}"); + await host.server.compile(document, undefined, { mode: "full" }); + + const diagnostics = host.getDiagnostics("./main.tsp"); + strictEqual(diagnostics.length, 1); + + const actions = await host.server.getCodeActions({ + textDocument: document, + range: Range.create(0, 0, 0, 0), + context: { diagnostics: [...diagnostics] }, + }); + + deepStrictEqual( + actions.map((x) => ({ title: x.title, kind: x.kind })), + [ + { title: "Use first suggestion", kind: CodeActionKind.QuickFix }, + { title: "Use second suggestion", kind: CodeActionKind.QuickFix }, + ], + ); + strictEqual(resolveCalls, 1); + + await host.server.resolveCodeAction(actions[1]); + strictEqual(appliedFix, "second"); +}); From 6ba7474b9fdff3f5015a8a9c372641d2dee41ac3 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 8 Jul 2026 03:30:06 +0800 Subject: [PATCH 4/6] Fix dynamic codefix server test import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/test/server/code-action.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/compiler/test/server/code-action.test.ts b/packages/compiler/test/server/code-action.test.ts index 6eb3f23880b..8a9e35cb749 100644 --- a/packages/compiler/test/server/code-action.test.ts +++ b/packages/compiler/test/server/code-action.test.ts @@ -1,8 +1,7 @@ import { deepStrictEqual, strictEqual } from "assert"; import { it } from "vitest"; import { CodeActionKind, Range } from "vscode-languageserver"; -import { createLinterRule } from "../../src/core/library.js"; -import { defineLinter } from "../../src/core/linter.js"; +import { createLinterRule, defineLinter } from "../../src/core/library.js"; import { createTestServerHost } from "../../src/testing/test-server-host.js"; it("expands dynamic codefixes into separate code actions", async () => { From c5eb4e05f727e968e83c2c5b678e40dc9b846af1 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 8 Jul 2026 04:08:25 +0800 Subject: [PATCH 5/6] Fix dynamic codefix server test assertion Ignore the standard suppress-warning quick fix and assert only dynamically resolved suggestions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/test/server/code-action.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/compiler/test/server/code-action.test.ts b/packages/compiler/test/server/code-action.test.ts index 8a9e35cb749..5c6479ca14b 100644 --- a/packages/compiler/test/server/code-action.test.ts +++ b/packages/compiler/test/server/code-action.test.ts @@ -94,8 +94,9 @@ it("expands dynamic codefixes into separate code actions", async () => { context: { diagnostics: [...diagnostics] }, }); + const dynamicActions = actions.filter((x) => x.title.startsWith("Use ")); deepStrictEqual( - actions.map((x) => ({ title: x.title, kind: x.kind })), + dynamicActions.map((x) => ({ title: x.title, kind: x.kind })), [ { title: "Use first suggestion", kind: CodeActionKind.QuickFix }, { title: "Use second suggestion", kind: CodeActionKind.QuickFix }, @@ -103,6 +104,6 @@ it("expands dynamic codefixes into separate code actions", async () => { ); strictEqual(resolveCalls, 1); - await host.server.resolveCodeAction(actions[1]); + await host.server.resolveCodeAction(dynamicActions[1]); strictEqual(appliedFix, "second"); }); From ed8c1ebdc4b95a1019b75688d31fbf87d82f36a0 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 8 Jul 2026 04:23:11 +0800 Subject: [PATCH 6/6] Add changeset for resolveCodefixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md diff --git a/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md b/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md new file mode 100644 index 00000000000..6aa4f311503 --- /dev/null +++ b/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add dynamic codefix expansion support for language-server code actions.