diff --git a/docs/tools/lsp.md b/docs/tools/lsp.md index cfc97551c..e7e848321 100644 --- a/docs/tools/lsp.md +++ b/docs/tools/lsp.md @@ -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** diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index 9f37e2468..2560b1f3b 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -158,7 +158,6 @@ function shouldTrimOpenAiCompactInputItem(item: Record): boolea return item.type === "function_call_output" || (item.type === "message" && item.role === "developer"); } - function shouldKeepOpenAiCompactOutputItem(item: Record): boolean { if (item.type === "compaction" || item.type === "compaction_summary") return true; if (item.type !== "message") return false; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ba041969a..cf2e26c50 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b089cf87..7c98f4b5f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 628bd7a3d..5c70a98a2 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -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 { - 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 { - 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"); 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 0607da7a3..7864a9265 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -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); + }); });