diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index db3e8f738..e7314ccbd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog ## [Unreleased] + ### Added +- Deferred diagnostics support in LSP writethrough: `onDeferredDiagnostics` callback and `deferredSignal` in `WritethroughOptions` allow callers to receive diagnostics that arrive after the main 5-second timeout +- Language detection for `.pm` (Perl modules), `.astro` (Astro framework), and special filenames `containerfile` and `justfile` +- Workspace-scoped diagnostics and reload actions via `*` file parameter; `diagnostics` action now supports `*` for workspace-wide diagnostics across all configured servers - Socket-mode DAP adapter support for debuggers like dlv that communicate via network sockets instead of stdio; Linux uses unix domain sockets, macOS/other platforms use TCP with client-addr dialing - Improved extensionless binary debugging: native debuggers (gdb, lldb-dap) and adapters with root markers are now preferred over unrelated adapters like debugpy - Debug tool with DAP (Debug Adapter Protocol) support for launching and attaching debuggers, setting breakpoints, stepping through execution, inspecting threads/stack/variables, and evaluating expressions @@ -20,6 +24,9 @@ ### Changed +- LSP diagnostics timeout reduced from 10 seconds to 5 seconds for faster feedback; slow diagnostics now fetch in background via deferred mechanism +- Diagnostics action error messaging clarified: requires `file` parameter or `*` for workspace scope; improved guidance in error responses +- Workspace symbols and reload actions now accept `*` to operate across all configured servers instead of requiring a file path - DAP session initialization now subscribes to stop events before launching/attaching to avoid missing stopOnEntry events - Stack frame fetching moved outside the event dispatch loop to prevent deadlocks and improve responsiveness - Evaluate requests now default to the top stopped frame when frameId is not explicitly provided diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 72cf86b99..250e44b93 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -692,8 +692,25 @@ export interface WritethroughOptions { enableFormat?: boolean; /** Whether to get LSP diagnostics after writing */ enableDiagnostics?: boolean; + /** Called when diagnostics arrive after the main timeout. */ + onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void; + /** Signal to cancel a pending deferred diagnostics fetch. */ + deferredSignal?: AbortSignal; } +/** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */ +type ResolvedWritethroughOptions = { + enableFormat: boolean; + enableDiagnostics: boolean; +}; + +/** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */ +export type WritethroughDeferredHandle = { + onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; + signal: AbortSignal; + finalize: (diagnostics: FileDiagnosticsResult | undefined) => void; +}; + /** Callback type for the LSP writethrough */ export type WritethroughCallback = ( dst: string, @@ -701,6 +718,7 @@ export type WritethroughCallback = ( signal?: AbortSignal, file?: BunFile, batch?: LspWritethroughBatchRequest, + getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ) => Promise; /** No-op writethrough callback */ @@ -709,6 +727,8 @@ export async function writethroughNoop( content: string, _signal?: AbortSignal, file?: BunFile, + _batch?: LspWritethroughBatchRequest, + _getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ): Promise { if (file) { await file.write(content); @@ -731,12 +751,12 @@ interface LspWritethroughBatchRequest { interface LspWritethroughBatchState { entries: Map; - options: Required; + options: ResolvedWritethroughOptions; } const writethroughBatches = new Map(); -function getOrCreateWritethroughBatch(id: string, options: Required): LspWritethroughBatchState { +function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughOptions): LspWritethroughBatchState { const existing = writethroughBatches.get(id); if (existing) { existing.options.enableFormat ||= options.enableFormat; @@ -787,7 +807,7 @@ function summarizeDiagnosticMessages(messages: string[]): { summary: string; err function mergeDiagnostics( results: Array, - options: Required, + options: ResolvedWritethroughOptions, ): FileDiagnosticsResult | undefined { const messages: string[] = []; const servers = new Set(); @@ -841,13 +861,41 @@ function mergeDiagnostics( }; } +async function scheduleDeferredDiagnosticsFetch(args: { + dst: string; + cwd: string; + servers: Array<[string, ServerConfig]>; + minVersions: ServerVersionMap | undefined; + expectedDocumentVersions: ServerVersionMap | undefined; + signal: AbortSignal; + callback: (diagnostics: FileDiagnosticsResult) => void; +}): Promise { + try { + const deferredTimeout = AbortSignal.timeout(25_000); + const combined = AbortSignal.any([args.signal, deferredTimeout]); + const diagnostics = await getDiagnosticsForFile(args.dst, args.cwd, args.servers, { + signal: combined, + minVersions: args.minVersions, + expectedDocumentVersions: args.expectedDocumentVersions, + }); + if (args.signal.aborted || diagnostics === undefined) return; + args.callback(diagnostics); + } catch { + // Cancelled or LSP gave up; silently discard. + } +} + async function runLspWritethrough( dst: string, content: string, cwd: string, - options: Required, + options: ResolvedWritethroughOptions, signal?: AbortSignal, file?: BunFile, + deferred?: { + onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; + signal: AbortSignal; + }, ): Promise { const { enableFormat, enableDiagnostics } = options; const config = getConfig(cwd); @@ -870,7 +918,7 @@ async function runLspWritethrough( let diagnostics: FileDiagnosticsResult | undefined; let timedOut = false; try { - const timeoutSignal = AbortSignal.timeout(10_000); + const timeoutSignal = AbortSignal.timeout(5_000); timeoutSignal.addEventListener( "abort", () => { @@ -927,6 +975,18 @@ async function runLspWritethrough( if (timedOut) { formatter = undefined; diagnostics = undefined; + // Schedule background diagnostic fetch if caller wants deferred results + if (deferred && !deferred.signal.aborted && enableDiagnostics) { + void scheduleDeferredDiagnosticsFetch({ + dst, + cwd, + servers, + minVersions, + expectedDocumentVersions, + signal: deferred.signal, + callback: deferred.onDeferredDiagnostics, + }); + } } await getWritePromise(); } @@ -947,22 +1007,32 @@ async function runLspWritethrough( async function flushWritethroughBatch( batch: PendingWritethrough[], cwd: string, - options: Required, + options: ResolvedWritethroughOptions, signal?: AbortSignal, + getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ): Promise { if (batch.length === 0) { return undefined; } const results: Array = []; for (const entry of batch) { - results.push(await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file)); + const bundle = getDeferred?.(entry.dst); + const deferredInner = + bundle && + ({ + onDeferredDiagnostics: bundle.onDeferredDiagnostics, + signal: bundle.signal, + } as const); + const diag = await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file, deferredInner); + bundle?.finalize(diag); + results.push(diag); } return mergeDiagnostics(results, options); } /** Create a writethrough callback for LSP aware write operations */ export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback { - const resolvedOptions: Required = { + const resolvedOptions: ResolvedWritethroughOptions = { enableFormat: options?.enableFormat ?? false, enableDiagnostics: options?.enableDiagnostics ?? false, }; @@ -975,9 +1045,19 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions signal?: AbortSignal, file?: BunFile, batch?: LspWritethroughBatchRequest, + getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined, ) => { if (!batch) { - return runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file); + const bundle = getDeferred?.(dst); + const deferredInner = + bundle && + ({ + onDeferredDiagnostics: bundle.onDeferredDiagnostics, + signal: bundle.signal, + } as const); + const diagnostics = await runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file, deferredInner); + bundle?.finalize(diagnostics); + return diagnostics; } const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions); @@ -989,7 +1069,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions } writethroughBatches.delete(batch.id); - return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal); + return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred); }; } @@ -1054,8 +1134,8 @@ export class LspTool implements AgentTool run workspace diagnostics across all configured servers const result = await runWorkspaceDiagnostics(this.session.cwd, signal); return { content: [ @@ -1068,6 +1148,18 @@ export class LspTool implements AgentTool = { ".zsh": "shellscript", ".fish": "fish", ".pl": "perl", + ".pm": "perl", ".php": "php", // JVM languages @@ -76,6 +77,7 @@ const LANGUAGE_MAP: Record = { ".less": "less", ".vue": "vue", ".svelte": "svelte", + ".astro": "astro", // Data formats ".json": "json", @@ -142,12 +144,15 @@ export function detectLanguageId(filePath: string): string { const basename = path.basename(filePath).toLowerCase(); // Handle special filenames - if (basename === "dockerfile" || basename.startsWith("dockerfile.")) { + if (basename === "dockerfile" || basename.startsWith("dockerfile.") || basename === "containerfile") { return "dockerfile"; } if (basename === "makefile" || basename === "gnumakefile") { return "makefile"; } + if (basename === "justfile") { + return "just"; + } if (basename === "cmakelists.txt" || ext === ".cmake") { return "cmake"; }