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:
+1
-1
@@ -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**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user