fix(lsp): adjusted LSP diagnostics polling to settle unversioned publishes

- Updated `waitForDiagnostics` to accept exact document-version matches immediately and otherwise wait for a quiescence window before using the latest publish.
- Removed the old unversioned-acceptance option and applied the settle-based wait logic through inline and deferred diagnostics fetch paths.
- Added an LSP writethrough regression test ensuring stale unversioned diagnostics are ignored in favor of later fresh publishes.
This commit is contained in:
can1357
2026-06-08 05:20:30 +02:00
parent e13f2de58a
commit b1563e4e80
6 changed files with 81 additions and 45 deletions
+1 -1
View File
@@ -73,7 +73,7 @@
**Execution**
- `file: "*"`: `runWorkspaceDiagnostics()` detects project type from root markers and runs one subprocess command: Rust `cargo check --message-format=short`, TypeScript `npx tsc --noEmit`, Go `go build ./...`, Python `pyright`.
- Concrete file or glob: `resolveDiagnosticTargets()` treats non-globs as one target, otherwise expands a `Bun.Glob` up to `MAX_GLOB_DIAGNOSTIC_TARGETS`.
- Per file, every matching server runs: custom clients call `lint(file)`; real LSP servers optionally wait for project load, capture `diagnosticsVersion`, `refreshFile()`, then `waitForDiagnostics()` for fresh `publishDiagnostics`.
- Per file, every matching server runs: custom clients call `lint(file)`; real LSP servers optionally wait for project load, capture `diagnosticsVersion`, `refreshFile()`, then `waitForDiagnostics()` for fresh `publishDiagnostics` (settles on the latest publish; exact-version match accepted immediately).
- Results are deduplicated by range+message and severity-sorted.
**Output text**
-1
View File
@@ -158,7 +158,6 @@ function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolea
return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
}
function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
if (item.type === "compaction" || item.type === "compaction_summary") return true;
if (item.type !== "message") return false;
-1
View File
@@ -2271,7 +2271,6 @@ function resolveAnthropicAdaptiveEffort(
return mapEffortToAnthropicAdaptiveEffort(model, requestedEffort);
}
function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): string {
for (const message of messages) {
if (message.role !== "user") continue;
+2
View File
@@ -12,6 +12,8 @@
### Fixed
- LSP writethrough no longer burns the full diagnostics poll on every edit/write. `typescript-language-server` never echoes the document version in `publishDiagnostics` ([upstream #983](https://github.com/typescript-language-server/typescript-language-server/issues/983)), so the exact-version gate never passed; `waitForDiagnostics` now accepts an exact version match instantly and otherwise settles on the latest publish after a short quiescence window, dropping superseded in-flight diagnostics.
- Fixed the `c`/`.` continue shortcut making the agent second-guess itself after an Esc interrupt. Continuing used to submit an *empty* user turn, which left the model with only the aborted-turn context — so it tended to restate the halted state and ask whether to proceed rather than just continuing. The shortcut now resumes with a hidden agent-authored `developer` directive ("keep going — don't stop to summarize or re-confirm the plan") instead of an empty turn. It still produces no visible transcript entry, same as before.
- Fixed native scrollback commit boundaries to be computed generically from finalized transcript blocks and observed append-only live growth, so tall final tool results and streaming previews keep their scrolled-off heads on ED3-risk terminals without per-tool append-only predicates; live blocks that re-layout remain deferred until finalization or the next checkpoint.
- Fixed read-group summaries for multi-path `read` results to use result-provided display targets so each resolved path is shown as its own row
+34 -42
View File
@@ -309,7 +309,7 @@ const DIAGNOSTICS_SETTLE_MS = 250;
* diagnostics before handing slow servers off to the deferred late-injection
* channel. Keeps the common fast-server case inline while letting an edit
* return promptly when a server (e.g. a large-monorepo tsserver) is slow to
* publish version-fresh diagnostics.
* publish fresh diagnostics.
*/
const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
/**
@@ -477,27 +477,15 @@ interface WaitForDiagnosticsOptions {
signal?: AbortSignal;
minVersion?: number;
expectedDocumentVersion?: number;
allowUnversioned?: boolean;
}
function getAcceptedDiagnostics(
publishedDiagnostics: PublishedDiagnostics | undefined,
expectedDocumentVersion?: number,
allowUnversioned = true,
): Diagnostic[] | undefined {
if (!publishedDiagnostics) {
return undefined;
}
if (expectedDocumentVersion === undefined) {
return publishedDiagnostics.diagnostics;
}
if (publishedDiagnostics.version === expectedDocumentVersion) {
return publishedDiagnostics.diagnostics;
}
if (allowUnversioned && publishedDiagnostics.version == null) {
return publishedDiagnostics.diagnostics;
}
return undefined;
/**
* Quiescence window (ms). typescript-language-server never echoes the document
* version (issue #983) and emits diagnostics from several sources at different
* times, so there is no single "complete, version-matched" publish to gate on.
* When the server does not exact-version-match, accept the latest publish only
* after no newer one has arrived for this long, letting an in-flight pre-edit
* publish be superseded by the fresh one.
*/
settleMs?: number;
}
async function waitForDiagnostics(
@@ -505,26 +493,35 @@ async function waitForDiagnostics(
uri: string,
options: WaitForDiagnosticsOptions = {},
): Promise<Diagnostic[]> {
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, allowUnversioned = true } = options;
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
const start = Date.now();
let settledRef: PublishedDiagnostics | undefined;
let settledAt = 0;
while (Date.now() - start < timeoutMs) {
throwIfAborted(signal);
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
const diagnostics = getAcceptedDiagnostics(
client.diagnostics.get(uri),
expectedDocumentVersion,
allowUnversioned,
);
if (diagnostics !== undefined && versionOk) {
return diagnostics;
const published = client.diagnostics.get(uri);
if (published && versionOk) {
// Server honored our exact document version → authoritative, accept now.
if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
return published.diagnostics;
}
// Unversioned/mismatched publish: wait for the stream to go quiet so an
// in-flight publish for the pre-edit content is superseded by the fresh one.
if (published !== settledRef) {
settledRef = published;
settledAt = Date.now();
} else if (Date.now() - settledAt >= settleMs) {
return published.diagnostics;
}
}
await Bun.sleep(100);
await Bun.sleep(DIAGNOSTICS_POLL_MS);
}
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
if (!versionOk) {
return [];
}
return getAcceptedDiagnostics(client.diagnostics.get(uri), expectedDocumentVersion, allowUnversioned) ?? [];
return client.diagnostics.get(uri)?.diagnostics ?? [];
}
/** Project type detection result */
@@ -629,7 +626,6 @@ interface GetDiagnosticsForFileOptions {
signal?: AbortSignal;
minVersions?: ServerVersionMap;
expectedDocumentVersions?: ServerVersionMap;
allowUnversionedLspDiagnostics?: boolean;
/** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
timeoutMs?: number;
}
@@ -687,7 +683,7 @@ async function getDiagnosticsForFile(
servers: Array<[string, ServerConfig]>,
options: GetDiagnosticsForFileOptions = {},
): Promise<FileDiagnosticsResult | undefined> {
const { signal, minVersions, expectedDocumentVersions, allowUnversionedLspDiagnostics = true, timeoutMs } = options;
const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
if (servers.length === 0) {
return undefined;
}
@@ -723,7 +719,6 @@ async function getDiagnosticsForFile(
signal,
minVersion,
expectedDocumentVersion,
allowUnversioned: allowUnversionedLspDiagnostics,
});
return { serverName, diagnostics };
}),
@@ -1039,11 +1034,10 @@ async function scheduleDeferredDiagnosticsFetch(args: {
* language server.
*
* Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
* fresh, version-fresh result. Freshness is enforced by the pre-edit
* `minVersions` baseline, so we accept unversioned publishes
* (`allowUnversionedLspDiagnostics: true`) rather than waiting for an exact
* per-document version echo that servers like typescript-language-server rarely
* send in time. If nothing fresh arrives in the inline window and a deferred
* fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
* exact document-version matches return immediately, and unversioned/mismatched
* publishes must settle with no newer publish before inline acceptance. If
* nothing fresh arrives in the inline window and a deferred
* channel is available, the in-flight fetch is handed off to deliver late via
* `onDeferredDiagnostics`, and this returns `undefined` so the tool result
* lands immediately. Without a deferred channel (direct/CI callers) it blocks
@@ -1070,7 +1064,6 @@ async function fetchDiagnosticsWithDeferral(args: {
signal,
minVersions,
expectedDocumentVersions,
allowUnversionedLspDiagnostics: true,
}),
);
}
@@ -1080,7 +1073,6 @@ async function fetchDiagnosticsWithDeferral(args: {
signal: deferred.signal,
minVersions,
expectedDocumentVersions,
allowUnversionedLspDiagnostics: true,
timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
});
const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
@@ -103,4 +103,48 @@ describe("LSP diagnostics freshness", () => {
expect(result?.errored).toBe(false);
expect(await Bun.file(filePath).text()).toBe("export const value = 2;\n");
});
it("settles on the latest unversioned publish when the server never echoes a version", async () => {
const filePath = path.join(tempDir.path(), "example.ts");
const uri = fileToUri(filePath);
const client = createClient(tempDir.path(), TEST_SERVER);
client.openFiles.set(uri, { version: 1, languageId: "typescript" });
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined });
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]);
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => {
const syncedUri = fileToUri(syncedFilePath);
mockClient.diagnostics.delete(syncedUri);
const openFile = mockClient.openFiles.get(syncedUri);
if (openFile) {
openFile.version += 1;
} else {
mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" });
}
});
vi.spyOn(lspClient, "notifySaved").mockImplementation(async (mockClient, savedFilePath) => {
const savedUri = fileToUri(savedFilePath);
setTimeout(() => {
publishDiagnostics(mockClient, savedUri, [createDiagnostic("stale error")], null);
}, 10);
setTimeout(() => {
publishDiagnostics(mockClient, savedUri, [createDiagnostic("real error")], null);
}, 150);
});
const writethrough = createLspWritethrough(tempDir.path(), {
enableFormat: false,
enableDiagnostics: true,
});
const t0 = Date.now();
const result = await writethrough(filePath, "export const value: number = 'x';\n");
const elapsed = Date.now() - t0;
expect(result).toBeDefined();
expect(result?.errored).toBe(true);
expect(result?.messages.some(m => m.includes("real error"))).toBe(true);
expect(result?.messages.some(m => m.includes("stale error"))).toBe(false);
expect(elapsed).toBeLessThan(1500);
});
});