From 75fc4d65bb5e35e1edf3061db7c4f96c03168790 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 3 Jul 2026 15:48:48 +0000 Subject: [PATCH 1/3] fix(lsp): notified servers about harness file writes Announced harness-authored create, change, and delete operations to active LSP clients with workspace/didChangeWatchedFiles before edit-time diagnostics are read. Added regression coverage for non-LSP sibling files in a batched write so diagnostics see the workspace state the harness just produced. Fixes #4459 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/edit/hashline/filesystem.ts | 18 ++++++ packages/coding-agent/src/edit/modes/patch.ts | 8 +++ packages/coding-agent/src/lsp/client.ts | 63 +++++++++++++++++++ packages/coding-agent/src/lsp/index.ts | 51 ++++++++++++--- packages/coding-agent/src/tools/acp-bridge.ts | 7 ++- .../tools/lsp-diagnostics-freshness.test.ts | 62 ++++++++++++++++++ 7 files changed, 202 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 860266c53..50d23d686 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed LSP diagnostics staleness after harness-authored file writes by sending watched-file change notifications to running language servers before edit-time diagnostics are read ([#4459](https://github.com/can1357/oh-my-pi/issues/4459)). + ## [16.3.4] - 2026-07-03 ### Fixed diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index 41f15842b..8f035170c 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -21,6 +21,7 @@ import * as path from "node:path"; import { Filesystem, NotFoundError, type PreflightWriteOptions, type WriteResult } from "@oh-my-pi/hashline"; import { isEnoent } from "@oh-my-pi/pi-utils"; import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; +import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client"; import type { ToolSession } from "../../tools"; import { routeWriteThroughBridge } from "../../tools/acp-bridge"; import { assertEditableFileContent } from "../../tools/auto-generated-guard"; @@ -157,6 +158,13 @@ export class HashlineFilesystem extends Filesystem { if (isEnoent(error)) throw new NotFoundError(relativePath, error); throw error; } + if (this.session.enableLsp ?? true) { + await notifyWorkspaceWatchedFiles( + this.session.cwd, + [{ filePath: absolutePath, type: FileChangeType.Deleted }], + this.#signal, + ); + } invalidateFsScanAfterWrite(absolutePath); } @@ -170,6 +178,16 @@ export class HashlineFilesystem extends Filesystem { } else { await fs.rename(fromAbsolute, toAbsolute); } + if (this.session.enableLsp ?? true) { + await notifyWorkspaceWatchedFiles( + this.session.cwd, + [ + { filePath: fromAbsolute, type: FileChangeType.Deleted }, + { filePath: toAbsolute, type: FileChangeType.Created }, + ], + this.#signal, + ); + } invalidateFsScanAfterWrite(fromAbsolute); invalidateFsScanAfterWrite(toAbsolute); } diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index c656e877b..7c8cc9b0f 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -16,6 +16,7 @@ import { type WritethroughCallback, type WritethroughDeferredHandle, } from "../../lsp"; +import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client"; import type { ToolSession } from "../../tools"; import { routeWriteThroughBridge } from "../../tools/acp-bridge"; import { assertEditableFile } from "../../tools/auto-generated-guard"; @@ -1753,6 +1754,13 @@ class LspFileSystem implements FileSystem { async delete(path: string): Promise { await this.#getFile(path).unlink(); + if (this.session.enableLsp ?? true) { + await notifyWorkspaceWatchedFiles( + this.session.cwd, + [{ filePath: path, type: FileChangeType.Deleted }], + this.signal, + ); + } } async mkdir(path: string): Promise { diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index 45bff1aa0..e0d248a3a 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -161,6 +161,10 @@ const CLIENT_CAPABILITIES = { valueSet: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26], }, }, + didChangeWatchedFiles: { + dynamicRegistration: true, + relativePatternSupport: true, + }, fileOperations: { dynamicRegistration: false, willCreate: false, @@ -176,6 +180,19 @@ const CLIENT_CAPABILITIES = { }, }; +/** LSP `FileChangeType` values for workspace/didChangeWatchedFiles notifications. */ +export enum FileChangeType { + Created = 1, + Changed = 2, + Deleted = 3, +} + +/** Filesystem change authored by the harness and announced to active LSP clients. */ +export interface WatchedFileChange { + filePath: string; + type: FileChangeType; +} + // ============================================================================= // LSP Message Protocol // ============================================================================= @@ -944,6 +961,52 @@ export async function notifySaved(client: LspClient, filePath: string, signal?: client.lastActivity = Date.now(); } +function isPathInsideWorkspace(filePath: string, workspace: string): boolean { + const relative = path.relative(workspace, path.resolve(filePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * Announce harness-authored filesystem changes to active LSP clients for `cwd`. + * + * This covers sibling files that are not open text documents, such as generated + * CSS modules or type files that another edited document imports immediately. + */ +export async function notifyWorkspaceWatchedFiles( + cwd: string, + changes: readonly WatchedFileChange[], + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + if (changes.length === 0) return; + + const workspace = path.resolve(cwd); + const activeClients = Array.from(clients.values()).filter( + client => client.status === "ready" && path.resolve(client.cwd) === workspace, + ); + if (activeClients.length === 0) return; + + const results = await Promise.allSettled( + activeClients.map(async client => { + const clientChanges = changes + .filter(change => isPathInsideWorkspace(change.filePath, workspace)) + .map(change => { + const uri = fileToUri(change.filePath); + client.diagnostics.delete(uri); + return { uri, type: change.type }; + }); + if (clientChanges.length === 0) return; + await sendNotification(client, "workspace/didChangeWatchedFiles", { changes: clientChanges }, signal); + }), + ); + throwIfAborted(signal); + for (const result of results) { + if (result.status === "rejected") { + logger.debug("LSP watched-files notification failed", { cwd, error: String(result.reason) }); + } + } +} + /** * Refresh a file in the LSP client. * Increments version, sends didChange and didSave notifications. diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index fea5f2341..9705d3151 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -18,10 +18,12 @@ import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors" import { clampTimeout } from "../tools/tool-timeouts"; import { ensureFileOpen, + FileChangeType, getActiveClients, getOrCreateClient, type LspServerStatus, notifySaved, + notifyWorkspaceWatchedFiles, refreshFile, sendNotification, sendRequest, @@ -904,6 +906,7 @@ interface PendingWritethrough { dst: string; content: string; file?: BunFile; + changeType: FileChangeType; } interface LspWritethroughBatchRequest { @@ -1097,6 +1100,7 @@ async function runLspWritethrough( content: string, cwd: string, options: ResolvedWritethroughOptions, + changeType: FileChangeType, signal?: AbortSignal, file?: BunFile, deferred?: { @@ -1107,14 +1111,22 @@ async function runLspWritethrough( const { enableFormat, enableDiagnostics } = options; const config = getConfig(cwd); const servers = getServersForFile(config, dst); - if (servers.length === 0) { - return writethroughNoop(dst, content, signal, file); - } - const { lspServers, customLinterServers } = splitServers(servers); let finalContent = content; const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value)); const getWritePromise = once(() => writeContent(finalContent)); + let writeNotified = false; + const notifyWriteCommitted = async () => { + if (writeNotified) return; + writeNotified = true; + await notifyWorkspaceWatchedFiles(cwd, [{ filePath: dst, type: changeType }], signal); + }; + if (servers.length === 0) { + await getWritePromise(); + await notifyWriteCommitted(); + return undefined; + } + const { lspServers, customLinterServers } = splitServers(servers); const useCustomFormatter = enableFormat && customLinterServers.length > 0; // Capture diagnostic versions BEFORE syncing to detect stale diagnostics @@ -1144,6 +1156,7 @@ async function runLspWritethrough( finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal); formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED; await writeContent(finalContent); + await notifyWriteCommitted(); await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal); } else { // 1. Sync original content to LSP servers @@ -1162,6 +1175,7 @@ async function runLspWritethrough( // 4. Write to disk await getWritePromise(); + await notifyWriteCommitted(); } if (enableDiagnostics) { @@ -1190,6 +1204,7 @@ async function runLspWritethrough( } } await getWritePromise(); + await notifyWriteCommitted(); } if (synced && enableDiagnostics) { @@ -1237,7 +1252,16 @@ async function flushWritethroughBatch( onDeferredDiagnostics: bundle.onDeferredDiagnostics, signal: bundle.signal, } as const); - const diag = await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file, deferredInner); + const diag = await runLspWritethrough( + entry.dst, + entry.content, + cwd, + options, + entry.changeType, + signal, + entry.file, + deferredInner, + ); bundle?.finalize(diag); results.push(diag); } @@ -1251,9 +1275,6 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions enableDiagnostics: options?.enableDiagnostics ?? false, transformDiagnostics: options?.transformDiagnostics, }; - if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) { - return writethroughNoop; - } return async ( dst: string, content: string, @@ -1262,6 +1283,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions batch?: LspWritethroughBatchRequest, getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ) => { + const changeType = (await Bun.file(dst).exists()) ? FileChangeType.Changed : FileChangeType.Created; if (!batch) { const bundle = getDeferred?.(dst); const deferredInner = @@ -1270,13 +1292,22 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions onDeferredDiagnostics: bundle.onDeferredDiagnostics, signal: bundle.signal, } as const); - const diagnostics = await runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file, deferredInner); + const diagnostics = await runLspWritethrough( + dst, + content, + cwd, + resolvedOptions, + changeType, + signal, + file, + deferredInner, + ); bundle?.finalize(diagnostics); return diagnostics; } const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions); - state.entries.set(dst, { dst, content, file }); + state.entries.set(dst, { dst, content, file, changeType }); if (!batch.flush) { await writethroughNoop(dst, content, signal, file); diff --git a/packages/coding-agent/src/tools/acp-bridge.ts b/packages/coding-agent/src/tools/acp-bridge.ts index b0f656fd7..8a32d5e41 100644 --- a/packages/coding-agent/src/tools/acp-bridge.ts +++ b/packages/coding-agent/src/tools/acp-bridge.ts @@ -7,6 +7,8 @@ * URLs) are always written directly to disk — those are OMP-owned and should * never be pushed into the editor. */ + +import { FileChangeType, notifyWorkspaceWatchedFiles } from "../lsp/client"; import type { ToolSession } from "."; import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation"; import { isInternalUrlPath } from "./path-utils"; @@ -59,12 +61,15 @@ export async function routeWriteThroughBridge( const bridge = session.getClientBridge?.(); if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return false; + const changeType = (await Bun.file(absolutePath).exists()) ? FileChangeType.Changed : FileChangeType.Created; try { await bridge.writeTextFile({ path: absolutePath, content }); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } - + if (session.enableLsp ?? true) { + await notifyWorkspaceWatchedFiles(session.cwd, [{ filePath: absolutePath, type: changeType }]); + } invalidateFsScanAfterWrite(absolutePath); session.bumpFileMutationVersion?.(absolutePath); return true; diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 29c135bae..155d7e498 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -112,6 +112,68 @@ describe("LSP diagnostics freshness", () => { tempDir.removeSync(); }); + it("announces watched-file creates even when no server owns the file type", async () => { + const filePath = path.join(tempDir.path(), "probe.module.scss"); + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); + const notify = vi.spyOn(lspClient, "notifyWorkspaceWatchedFiles").mockResolvedValue(); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: false, + }); + const result = await writethrough(filePath, ".section {}\n"); + + expect(result).toBeUndefined(); + expect(await Bun.file(filePath).text()).toBe(".section {}\n"); + expect(notify).toHaveBeenCalledWith( + tempDir.path(), + [{ filePath, type: lspClient.FileChangeType.Created }], + undefined, + ); + }); + + it("announces batched sibling writes before syncing the diagnostic target", async () => { + const stylesPath = path.join(tempDir.path(), "probe.module.scss"); + const tsPath = path.join(tempDir.path(), "probe.tsx"); + const tsUri = fileToUri(tsPath); + const client = createClient(tempDir.path(), TEST_SERVER); + const events: string[] = []; + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockImplementation((_config, filePath) => + filePath.endsWith(".module.scss") ? [] : [["test-lsp", TEST_SERVER]], + ); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "notifyWorkspaceWatchedFiles").mockImplementation(async (_cwd, changes) => { + for (const change of changes) { + events.push(`watched:${path.basename(change.filePath)}:${change.type}`); + } + }); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + events.push(`sync:${path.basename(syncedFilePath)}`); + const syncedUri = fileToUri(syncedFilePath); + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + }); + vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => { + publishDiagnostics(mockClient, tsUri, [], mockClient.openFiles.get(tsUri)?.version ?? null); + }); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: true, + }); + await writethrough(stylesPath, ".section {}\n", undefined, undefined, { id: "batch", flush: false }); + const result = await writethrough(tsPath, 'import styles from "./probe.module.scss";\n', undefined, undefined, { + id: "batch", + flush: true, + }); + + expect(result?.summary).toBe("no issues"); + expect(events[0]).toBe(`watched:probe.module.scss:${lspClient.FileChangeType.Created}`); + expect(events).toContain("sync:probe.tsx"); + }); + it("suppresses stale write diagnostics until the matching document version arrives", async () => { const filePath = path.join(tempDir.path(), "example.ts"); const uri = fileToUri(filePath); From 065f0da7c2cc03d9aa7c73cd5ea16707afc8c218 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 3 Jul 2026 15:57:42 +0000 Subject: [PATCH 2/3] fix(lsp): threaded tool abort signal through ACP-bridge notify Bridge-backed writes now respect tool timeout/cancel: routeWriteThroughBridge takes an optional AbortSignal and forwards it to notifyWorkspaceWatchedFiles, so a wedged LSP server no longer hangs the bridge path. Updated write, replace, patch, and hashline callers to pass the tool signal. Refs #4459 --- packages/coding-agent/src/edit/hashline/filesystem.ts | 2 +- packages/coding-agent/src/edit/modes/patch.ts | 2 +- packages/coding-agent/src/edit/modes/replace.ts | 2 +- packages/coding-agent/src/tools/acp-bridge.ts | 3 ++- packages/coding-agent/src/tools/write.ts | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index 8f035170c..cfe6f96f5 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -198,7 +198,7 @@ export class HashlineFilesystem extends Filesystem { const finalContent = await serializeEditFileText(absolutePath, relativePath, content); // Route through ACP bridge when available; skips internal artifacts. - if (await routeWriteThroughBridge(this.session, relativePath, absolutePath, finalContent)) { + if (await routeWriteThroughBridge(this.session, relativePath, absolutePath, finalContent, this.#signal)) { this.#diagnosticsByPath.set(relativePath, undefined); return { text: finalContent }; } diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 7c8cc9b0f..90a9772e2 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -1733,7 +1733,7 @@ class LspFileSystem implements FileSystem { const finalContent = await serializeEditFileText(path, path, content); // Route through ACP bridge when available; skips internal artifacts and local:// paths. - if (await routeWriteThroughBridge(this.session, this.requestedPath, path, finalContent)) { + if (await routeWriteThroughBridge(this.session, this.requestedPath, path, finalContent, this.signal)) { return; } diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index 9c910e34b..1b5b88902 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -1103,7 +1103,7 @@ export async function executeReplaceSingle( // Route through ACP bridge when available; skips internal artifacts. let diagnostics: FileDiagnosticsResult | undefined; - if (await routeWriteThroughBridge(session, path, absolutePath, finalContent)) { + if (await routeWriteThroughBridge(session, path, absolutePath, finalContent, signal)) { // bridge handled the write; diagnostics not available via writethrough } else { diagnostics = await writethrough(absolutePath, finalContent, signal, Bun.file(absolutePath), batchRequest, dst => diff --git a/packages/coding-agent/src/tools/acp-bridge.ts b/packages/coding-agent/src/tools/acp-bridge.ts index 8a32d5e41..2ffba4f54 100644 --- a/packages/coding-agent/src/tools/acp-bridge.ts +++ b/packages/coding-agent/src/tools/acp-bridge.ts @@ -55,6 +55,7 @@ export async function routeWriteThroughBridge( requestedPath: string, absolutePath: string, content: string, + signal?: AbortSignal, ): Promise { if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return false; @@ -68,7 +69,7 @@ export async function routeWriteThroughBridge( throw new ToolError(error instanceof Error ? error.message : String(error)); } if (session.enableLsp ?? true) { - await notifyWorkspaceWatchedFiles(session.cwd, [{ filePath: absolutePath, type: changeType }]); + await notifyWorkspaceWatchedFiles(session.cwd, [{ filePath: absolutePath, type: changeType }], signal); } invalidateFsScanAfterWrite(absolutePath); session.bumpFileMutationVersion?.(absolutePath); diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 78b7e54d3..6f5738abb 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -914,7 +914,7 @@ export class WriteTool implements AgentTool Date: Fri, 3 Jul 2026 23:27:10 +0000 Subject: [PATCH 3/3] fix(lsp): bounded watched-file notifications during writes Watched-file notifications sent from the LSP writethrough now use the same bounded operation signal as sync/save notifications, so a stalled server cannot hang the write after disk commit. Extended the freshness regression to assert a bounded signal reaches watched-file notification sends. Refs #4459 --- packages/coding-agent/src/lsp/index.ts | 18 ++++++++++++------ .../tools/lsp-diagnostics-freshness.test.ts | 5 ++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 9705d3151..9638ab4da 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -1116,10 +1116,15 @@ async function runLspWritethrough( const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value)); const getWritePromise = once(() => writeContent(finalContent)); let writeNotified = false; - const notifyWriteCommitted = async () => { + const notifyWriteCommitted = async (notifySignal: AbortSignal | undefined = signal) => { if (writeNotified) return; writeNotified = true; - await notifyWorkspaceWatchedFiles(cwd, [{ filePath: dst, type: changeType }], signal); + try { + await notifyWorkspaceWatchedFiles(cwd, [{ filePath: dst, type: changeType }], notifySignal); + } catch (error) { + if (notifySignal?.aborted && !signal?.aborted) return; + throw error; + } }; if (servers.length === 0) { await getWritePromise(); @@ -1139,6 +1144,7 @@ async function runLspWritethrough( let diagnostics: FileDiagnosticsResult | undefined; let timedOut = false; let synced = false; + let operationSignal: AbortSignal | undefined; try { const timeoutSignal = AbortSignal.timeout(5_000); timeoutSignal.addEventListener( @@ -1148,7 +1154,7 @@ async function runLspWritethrough( }, { once: true }, ); - const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; await untilAborted(operationSignal, async () => { if (useCustomFormatter) { // Custom linters (e.g. Biome CLI) require on-disk input. @@ -1156,7 +1162,7 @@ async function runLspWritethrough( finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal); formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED; await writeContent(finalContent); - await notifyWriteCommitted(); + await notifyWriteCommitted(operationSignal); await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal); } else { // 1. Sync original content to LSP servers @@ -1175,7 +1181,7 @@ async function runLspWritethrough( // 4. Write to disk await getWritePromise(); - await notifyWriteCommitted(); + await notifyWriteCommitted(operationSignal); } if (enableDiagnostics) { @@ -1204,7 +1210,7 @@ async function runLspWritethrough( } } await getWritePromise(); - await notifyWriteCommitted(); + await notifyWriteCommitted(operationSignal); } if (synced && enableDiagnostics) { diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 155d7e498..8bdb6cbb3 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -139,13 +139,15 @@ describe("LSP diagnostics freshness", () => { const tsUri = fileToUri(tsPath); const client = createClient(tempDir.path(), TEST_SERVER); const events: string[] = []; + const notifySignals: Array = []; vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); vi.spyOn(lspConfig, "getServersForFile").mockImplementation((_config, filePath) => filePath.endsWith(".module.scss") ? [] : [["test-lsp", TEST_SERVER]], ); vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); - vi.spyOn(lspClient, "notifyWorkspaceWatchedFiles").mockImplementation(async (_cwd, changes) => { + vi.spyOn(lspClient, "notifyWorkspaceWatchedFiles").mockImplementation(async (_cwd, changes, notifySignal) => { + notifySignals.push(notifySignal); for (const change of changes) { events.push(`watched:${path.basename(change.filePath)}:${change.type}`); } @@ -171,6 +173,7 @@ describe("LSP diagnostics freshness", () => { expect(result?.summary).toBe("no issues"); expect(events[0]).toBe(`watched:probe.module.scss:${lspClient.FileChangeType.Created}`); + expect(notifySignals.some(signal => signal instanceof AbortSignal)).toBe(true); expect(events).toContain("sync:probe.tsx"); });