feat(lsp): expose captured diagnostics (#1813)

This commit is contained in:
Bogdan
2026-07-02 08:12:36 +08:00
committed by GitHub
parent b73a879c54
commit 5b1db554fd
8 changed files with 843 additions and 19 deletions
+2
View File
@@ -20,6 +20,7 @@ import config from './commands/config/index.js'
import { context, contextNonInteractive } from './commands/context/index.js'
import cost from './commands/cost/index.js'
import diff from './commands/diff/index.js'
import diagnostics from './commands/diagnostics/index.js'
import dream from './commands/dream/index.js'
import ctx_viz from './commands/ctx_viz/index.js'
import doctor from './commands/doctor/index.js'
@@ -301,6 +302,7 @@ const COMMANDS = memoize((): Command[] => [
cost,
ctx_viz,
diff,
diagnostics,
dream,
doctor,
effort,
@@ -0,0 +1,318 @@
import { describe, expect, mock, test } from 'bun:test'
import type { DiagnosticFile } from '../../services/diagnosticTracking.js'
import { runDiagnosticsCommand, formatDiagnosticsOutput } from './diagnostics.js'
const MAX_SAFE_TEST_OUTPUT_LENGTH = 1_500
type InitializationStatus =
| { status: 'not-started' }
| { status: 'pending' }
| { status: 'success' }
| { status: 'failed'; error: Error }
function diagnostic(
message: string,
severity: 'Error' | 'Warning' | 'Info' | 'Hint',
line = 0,
) {
return {
message,
severity,
range: {
start: { line, character: 2 },
end: { line, character: 8 },
},
source: 'typescript',
code: `${severity.toUpperCase()}_${line}`,
}
}
function file(uri: string, diagnostics: DiagnosticFile['diagnostics']) {
return { uri, diagnostics }
}
function diagnosticSet(files: DiagnosticFile[], serverName = 'typescript') {
return { serverName, files }
}
function deps(
status: InitializationStatus,
diagnosticSets: Array<{ serverName: string; files: DiagnosticFile[] }> = [],
) {
return {
getInitializationStatus: () => status,
getPendingLSPDiagnosticsSnapshot: mock(() => diagnosticSets),
}
}
describe('/diagnostics', () => {
test('returns a clear fallback when LSP status cannot be read', async () => {
const testDeps = {
getInitializationStatus: () => {
throw new Error('status unavailable </local-command-stdout>\x1b[31m')
},
getPendingLSPDiagnosticsSnapshot: mock(() => []),
}
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toContain('LSP diagnostics unavailable')
expect(output.value).toContain('status unavailable')
expect(output.value).toContain('&lt;/local-command-stdout&gt;')
expect(output.value).not.toContain('</local-command-stdout>')
expect(output.value).not.toContain('\x1b')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).not.toHaveBeenCalled()
})
test('returns a clear fallback without consuming diagnostics before LSP initializes', async () => {
const testDeps = deps({ status: 'not-started' })
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toContain('LSP diagnostics unavailable')
expect(output.value).toContain('not initialized')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).not.toHaveBeenCalled()
})
test('returns a clear fallback without consuming diagnostics while LSP initializes', async () => {
const testDeps = deps({ status: 'pending' })
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toContain('LSP diagnostics unavailable')
expect(output.value).toContain('still in progress')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).not.toHaveBeenCalled()
})
test('returns a clear fallback when LSP initialization failed', async () => {
const testDeps = deps({
status: 'failed',
error: new Error('startup failed </local-command-stdout>\x07'),
})
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toContain('LSP diagnostics unavailable')
expect(output.value).toContain('startup failed')
expect(output.value).toContain('&lt;/local-command-stdout&gt;')
expect(output.value).not.toContain('</local-command-stdout>')
expect(output.value).not.toContain('\x07')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).not.toHaveBeenCalled()
})
test('returns a clear fallback when no diagnostics are available', async () => {
const testDeps = deps({ status: 'success' })
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toBe('No LSP diagnostics available.')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).toHaveBeenCalledTimes(1)
})
test('returns a clear fallback when diagnostic files contain no diagnostics', async () => {
const testDeps = deps({ status: 'success' }, [
{
serverName: 'typescript',
files: [file('/repo/src/clean.ts', [])],
},
])
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toBe('No LSP diagnostics available.')
expect(testDeps.getPendingLSPDiagnosticsSnapshot).toHaveBeenCalledTimes(1)
})
test('returns a clear fallback when diagnostics cannot be read', async () => {
const testDeps = {
getInitializationStatus: () => ({ status: 'success' }) as const,
getPendingLSPDiagnosticsSnapshot: mock(() => {
throw new Error('registry unavailable </local-command-stdout>\x1b[31m')
}),
}
const output = await runDiagnosticsCommand('', testDeps)
expect(output.value).toContain('LSP diagnostics unavailable')
expect(output.value).toContain('registry unavailable')
expect(output.value).toContain('&lt;/local-command-stdout&gt;')
expect(output.value).not.toContain('</local-command-stdout>')
expect(output.value).not.toContain('\x1b')
})
test('groups diagnostics by file and severity with stable severity ordering', async () => {
const testDeps = deps(
{ status: 'success' },
[
{
serverName: 'typescript',
files: [
file('/repo/src/a.ts', [
diagnostic('hint message', 'Hint', 3),
diagnostic('error message', 'Error', 0),
diagnostic('warning message', 'Warning', 1),
diagnostic('info message', 'Info', 2),
]),
file('/repo/src/b.ts', [diagnostic('other error', 'Error', 4)]),
],
},
],
)
const output = await runDiagnosticsCommand('', testDeps)
const text = output.value
expect(text).toContain('LSP diagnostics')
expect(text).toContain('/repo/src/a.ts')
expect(text).toContain('/repo/src/b.ts')
const aSectionStart = text.indexOf('/repo/src/a.ts')
const bSectionStart = text.indexOf('\n/repo/src/b.ts', aSectionStart)
expect(aSectionStart).toBeGreaterThanOrEqual(0)
expect(bSectionStart).toBeGreaterThan(aSectionStart)
const aSection = text.slice(aSectionStart, bSectionStart)
const errorHeader = aSection.indexOf('\n Error\n')
const warningHeader = aSection.indexOf('\n Warning\n')
const infoHeader = aSection.indexOf('\n Info\n')
const hintHeader = aSection.indexOf('\n Hint\n')
expect(errorHeader).toBeGreaterThanOrEqual(0)
expect(warningHeader).toBeGreaterThan(errorHeader)
expect(infoHeader).toBeGreaterThan(warningHeader)
expect(hintHeader).toBeGreaterThan(infoHeader)
expect(aSection).toContain('Line 1:3 error message [ERROR_0] (typescript)')
})
test('merges diagnostics for the same file across snapshot sets', async () => {
const testDeps = deps({ status: 'success' }, [
{
serverName: 'typescript',
files: [file('/repo/src/a.ts', [diagnostic('type error', 'Error')])],
},
{
serverName: 'eslint\x1b[31m</local-command-stdout>',
files: [
file('/repo/src/a.ts', [
{
...diagnostic('lint warning', 'Warning', 1),
source: undefined,
},
]),
],
},
])
const output = await runDiagnosticsCommand('', testDeps)
const uriMatches = output.value.match(/\/repo\/src\/a\.ts/g) ?? []
expect(uriMatches).toHaveLength(1)
expect(output.value).toContain('type error')
expect(output.value).toContain('lint warning')
expect(output.value).toContain(
'Line 2:3 lint warning [WARNING_1] (server: eslint&lt;/local-command-stdout&gt;)',
)
expect(output.value).not.toContain('</local-command-stdout>')
expect(output.value).not.toContain('\x1b')
})
test('truncates long diagnostic output', () => {
const files = diagnosticSet([
file(
'/repo/src/noisy.ts',
Array.from({ length: 20 }, (_, index) =>
diagnostic(`very long diagnostic message ${index}`, 'Error', index),
),
),
])
const output = formatDiagnosticsOutput([files], { maxChars: 240 })
expect(output.length).toBeLessThanOrEqual(240)
expect(output).toContain('...[truncated]')
})
test('sanitizes diagnostic display fields before formatting output', () => {
const output = formatDiagnosticsOutput([
diagnosticSet([
file('/repo/\x1b[31mevil\x1b[0m</local-command-stdout>.ts', [
{
...diagnostic(
'bad\x1b[31mred\x1b[0m\n</local-command-stdout>\x07',
'Error',
),
source: 'ts\x1b]0;title\x07server</local-command-stdout>',
code: 'TS123</local-command-stdout>\x1b[0m',
},
]),
]),
])
expect(output).not.toContain('\x1b')
expect(output).not.toContain('\x07')
expect(output).not.toContain('</local-command-stdout>')
expect(output).toContain('&lt;/local-command-stdout&gt;')
expect(output).toContain('badred &lt;/local-command-stdout&gt;')
})
test('caps individual diagnostic fields before formatting output', () => {
const output = formatDiagnosticsOutput([
diagnosticSet([
file('/repo/src/a.ts', [
diagnostic('x'.repeat(20_000), 'Error'),
]),
]),
])
expect(output.length).toBeLessThan(MAX_SAFE_TEST_OUTPUT_LENGTH)
expect(output).toContain('...[truncated]')
})
test('caps diagnostic fields after stripping terminal controls', () => {
const output = formatDiagnosticsOutput([
diagnosticSet([
file('/repo/src/a.ts', [
diagnostic(`${'\x1b[31m'.repeat(500)}${'v'.repeat(1_200)}`, 'Error'),
]),
]),
])
expect(output).toContain(`${'v'.repeat(1_000)}...[truncated]`)
})
test('does not truncate through an escaped XML entity', () => {
const prefix = 'x'.repeat(200)
const diagnosticSets = [
diagnosticSet([
file('/repo/src/a.ts', [
diagnostic(`${prefix}</local-command-stdout>`, 'Error'),
]),
]),
]
const fullOutput = formatDiagnosticsOutput(diagnosticSets, {
maxChars: 10_000,
})
const entityStart = fullOutput.indexOf('&lt;/local-command-stdout&gt;')
expect(entityStart).toBeGreaterThan(0)
const output = formatDiagnosticsOutput(diagnosticSets, {
maxChars: entityStart + 3 + '\n...[truncated]'.length,
})
expect(output).toContain('...[truncated]')
expect(output).not.toContain('</local-command-stdout>')
expect(output).not.toContain('&lt;/local-command-stdout')
expect(output.endsWith(`${prefix}\n...[truncated]`)).toBe(true)
})
test('keeps multiline diagnostic messages on a single output line', () => {
const output = formatDiagnosticsOutput([
diagnosticSet([
file('/repo/src/a.ts', [
diagnostic('first line\nsecond line', 'Error'),
]),
]),
])
expect(output).toContain('first line second line')
expect(output).not.toContain('first line\nsecond line')
})
})
+279
View File
@@ -0,0 +1,279 @@
import { stripVTControlCharacters } from 'node:util'
import type { Diagnostic } from '../../services/diagnosticTracking.js'
import {
getPendingLSPDiagnosticsSnapshot,
type LSPDiagnosticSet,
} from '../../services/lsp/LSPDiagnosticRegistry.js'
import { getInitializationStatus } from '../../services/lsp/manager.js'
import { toError } from '../../utils/errors.js'
import { escapeXml } from '../../utils/xml.js'
import type { LocalCommandCall, LocalCommandResult } from '../../types/command.js'
type InitializationStatus = ReturnType<typeof getInitializationStatus>
type TextCommandResult = Extract<LocalCommandResult, { type: 'text' }>
type DiagnosticEntry = { diagnostic: Diagnostic; serverName: string }
type GroupedDiagnosticFile = { uri: string; diagnostics: DiagnosticEntry[] }
export type DiagnosticsCommandDeps = {
getInitializationStatus: () => InitializationStatus
getPendingLSPDiagnosticsSnapshot: () => LSPDiagnosticSet[]
}
const DEFAULT_DEPS: DiagnosticsCommandDeps = {
getInitializationStatus,
getPendingLSPDiagnosticsSnapshot,
}
const MAX_DIAGNOSTICS_OUTPUT_CHARS = 4_000
const MAX_DISPLAY_FIELD_CHARS = 1_000
const MAX_TRUNCATION_LINE_BACKTRACK = 120
const FIELD_TRUNCATION_MARKER = '...[truncated]'
const TRUNCATION_MARKER = '\n...[truncated]'
const SEVERITY_ORDER: Diagnostic['severity'][] = [
'Error',
'Warning',
'Info',
'Hint',
]
export const call: LocalCommandCall = args =>
runDiagnosticsCommand(args, DEFAULT_DEPS)
export async function runDiagnosticsCommand(
_args: string,
deps: DiagnosticsCommandDeps = DEFAULT_DEPS,
): Promise<TextCommandResult> {
let status: InitializationStatus
try {
status = deps.getInitializationStatus()
} catch (error) {
return unavailable(toError(error).message)
}
if (status.status === 'not-started') {
return text(
'LSP diagnostics unavailable: LSP is not initialized. OpenClaude initializes LSP only after workspace trust is established.',
)
}
if (status.status === 'pending') {
return text(
'LSP diagnostics unavailable: LSP initialization is still in progress.',
)
}
if (status.status === 'failed') {
return unavailable(status.error.message)
}
let diagnosticSets: LSPDiagnosticSet[]
try {
diagnosticSets = deps.getPendingLSPDiagnosticsSnapshot()
} catch (error) {
return unavailable(toError(error).message)
}
if (
!diagnosticSets.some(set =>
set.files.some(file => file.diagnostics.length > 0),
)
) {
return text('No LSP diagnostics available.')
}
return text(formatDiagnosticsOutput(diagnosticSets))
}
export function formatDiagnosticsOutput(
diagnosticSets: LSPDiagnosticSet[],
options: { maxChars?: number } = {},
): string {
const maxChars = options.maxChars ?? MAX_DIAGNOSTICS_OUTPUT_CHARS
const output = createOutputBuilder(maxChars)
if (!output.appendLine('LSP diagnostics')) {
return output.value()
}
for (const file of mergeDiagnosticSets(diagnosticSets)) {
if (
!output.appendLine('') ||
!output.appendLine(sanitizeDisplayField(file.uri))
) {
return output.value()
}
for (const severity of SEVERITY_ORDER) {
const diagnostics = file.diagnostics
.filter(entry => entry.diagnostic.severity === severity)
.sort(compareDiagnosticEntries)
if (diagnostics.length === 0) {
continue
}
if (!output.appendLine(` ${severity}`)) {
return output.value()
}
for (const entry of diagnostics) {
if (!output.appendLine(` - ${formatDiagnostic(entry)}`)) {
return output.value()
}
}
}
}
return output.value()
}
function text(value: string): TextCommandResult {
return { type: 'text', value }
}
function unavailable(message: string): TextCommandResult {
return text(`LSP diagnostics unavailable: ${sanitizeDisplayField(message)}`)
}
function mergeDiagnosticSets(
diagnosticSets: LSPDiagnosticSet[],
): GroupedDiagnosticFile[] {
const byUri = new Map<string, DiagnosticEntry[]>()
for (const set of diagnosticSets) {
for (const file of set.files) {
if (file.diagnostics.length === 0) {
continue
}
const diagnostics = byUri.get(file.uri) ?? []
diagnostics.push(
...file.diagnostics.map(diagnostic => ({
diagnostic,
serverName: set.serverName,
})),
)
byUri.set(file.uri, diagnostics)
}
}
return Array.from(byUri.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([uri, diagnostics]) => ({ uri, diagnostics }))
}
function formatDiagnostic(entry: DiagnosticEntry): string {
const { diagnostic, serverName } = entry
const line = diagnostic.range.start.line + 1
const character = diagnostic.range.start.character + 1
const code = diagnostic.code
? ` [${sanitizeDisplayField(diagnostic.code)}]`
: ''
const provenance = formatProvenance(diagnostic, serverName)
const message = sanitizeDisplayField(diagnostic.message)
return `Line ${line}:${character} ${message}${code}${provenance}`
}
function formatProvenance(diagnostic: Diagnostic, serverName: string): string {
const source = diagnostic.source ? sanitizeDisplayField(diagnostic.source) : ''
const server = serverName ? sanitizeDisplayField(serverName) : ''
if (source && server && source !== server) {
return ` (${source}; server: ${server})`
}
if (source) {
return ` (${source})`
}
if (server) {
return ` (server: ${server})`
}
return ''
}
function sanitizeDisplayField(value: string): string {
const withoutControlSequences = stripVTControlCharacters(value)
const singleLine = withoutControlSequences
.replace(/\s*[\r\n]+\s*/g, ' ')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
const visible =
singleLine.length > MAX_DISPLAY_FIELD_CHARS
? singleLine.slice(0, MAX_DISPLAY_FIELD_CHARS) + FIELD_TRUNCATION_MARKER
: singleLine
// Text command results are embedded in local-command XML by some callers.
return escapeXml(visible)
}
function compareDiagnosticEntries(
a: DiagnosticEntry,
b: DiagnosticEntry,
): number {
const lineDelta =
a.diagnostic.range.start.line - b.diagnostic.range.start.line
if (lineDelta !== 0) return lineDelta
const characterDelta =
a.diagnostic.range.start.character - b.diagnostic.range.start.character
if (characterDelta !== 0) return characterDelta
const messageDelta = a.diagnostic.message.localeCompare(b.diagnostic.message)
if (messageDelta !== 0) return messageDelta
return a.serverName.localeCompare(b.serverName)
}
function truncateOutput(output: string, maxChars: number): string {
if (output.length <= maxChars) {
return output
}
if (maxChars <= TRUNCATION_MARKER.length) {
return TRUNCATION_MARKER.slice(0, Math.max(0, maxChars))
}
const limit = maxChars - TRUNCATION_MARKER.length
const lastLineBreak = output.lastIndexOf('\n', limit)
const lineBreakIsNearby =
lastLineBreak > 0 && limit - lastLineBreak <= MAX_TRUNCATION_LINE_BACKTRACK
const end = avoidSplitXmlEntity(
output,
lineBreakIsNearby ? lastLineBreak : limit,
)
return output.slice(0, end) + TRUNCATION_MARKER
}
function createOutputBuilder(maxChars: number): {
appendLine: (line: string) => boolean
value: () => string
} {
let output = ''
let truncated = false
return {
appendLine(line: string): boolean {
if (truncated) {
return false
}
const next = output.length === 0 ? line : `${output}\n${line}`
if (next.length > maxChars) {
output = truncateOutput(next, maxChars)
truncated = true
return false
}
output = next
return true
},
value(): string {
return output
},
}
}
function avoidSplitXmlEntity(output: string, end: number): number {
const lastAmpersand = output.lastIndexOf('&', end - 1)
if (lastAmpersand === -1) {
return end
}
const lastSemicolon = output.lastIndexOf(';', end - 1)
if (lastSemicolon > lastAmpersand) {
return end
}
const nextSemicolon = output.indexOf(';', lastAmpersand)
if (nextSemicolon >= end && nextSemicolon - lastAmpersand <= 10) {
return lastAmpersand
}
return end
}
+40
View File
@@ -0,0 +1,40 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, test } from 'bun:test'
import { clearCommandMemoizationCaches, getCommands } from '../../commands.js'
test('includes the diagnostics command', async () => {
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-diagnostics-'))
try {
const commands = await getCommands(cwd)
const command = commands.find(command => command.name === 'diagnostics')
expect(command).toBeDefined()
expect(command?.type).toBe('local')
if (command?.type !== 'local') {
throw new Error('Expected diagnostics to be a local command')
}
expect(command.supportsNonInteractive).toBe(true)
const loaded = await command.load()
expect(typeof loaded.call).toBe('function')
} finally {
await rm(cwd, { recursive: true, force: true })
clearCommandMemoizationCaches()
}
})
test('includes the diagnostics command in the web catalog', async () => {
const webCommandsModule = (await import(
new URL('../../../web/src/data/commands.ts', import.meta.url).href
)) as {
commands: Array<{ name: string; description: string; category: string }>
}
expect(webCommandsModule.commands).toContainEqual({
name: 'diagnostics',
description: 'Show available LSP diagnostics already captured for this session',
category: 'diagnostics',
})
})
+11
View File
@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const diagnostics = {
type: 'local',
name: 'diagnostics',
description: 'Show available LSP diagnostics already captured for this session',
supportsNonInteractive: true,
load: () => import('./diagnostics.js'),
} satisfies Command
export default diagnostics
@@ -87,6 +87,99 @@ describe('LSPDiagnosticRegistry storm control', () => {
expect(registry.checkForLSPDiagnostics()).toEqual([])
})
test('snapshots pending diagnostics without consuming delivery', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const snapshot = registry.getPendingLSPDiagnosticsSnapshot()
expect(snapshot).toHaveLength(1)
expect(snapshot[0]?.files).toEqual([file])
expect(registry.getPendingLSPDiagnosticCount()).toBe(1)
const delivered = registry.checkForLSPDiagnostics()
expect(delivered).toHaveLength(1)
expect(delivered[0]?.files).toEqual([file])
expect(registry.getPendingLSPDiagnosticCount()).toBe(0)
})
test('returned pending snapshot is detached from registry state', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
const expected = diagnosticFile('/repo/a.ts', ['same missing import'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const snapshot = registry.getPendingLSPDiagnosticsSnapshot()
snapshot[0]!.files[0]!.diagnostics[0]!.message = 'mutated by caller'
snapshot[0]!.files[0]!.diagnostics.push(diagnostic('extra mutation', 99))
snapshot[0]!.files.push(diagnosticFile('/repo/extra.ts', ['extra file']))
expect(registry.getPendingLSPDiagnosticCount()).toBe(1)
const delivered = registry.checkForLSPDiagnostics()
expect(delivered).toHaveLength(1)
expect(delivered[0]?.files).toEqual([expected])
expect(registry.getPendingLSPDiagnosticCount()).toBe(0)
})
test('snapshots pending diagnostics even when delivery would filter unchanged diagnostics', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
expect(registry.checkForLSPDiagnostics()).toHaveLength(1)
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const snapshot = registry.getPendingLSPDiagnosticsSnapshot()
expect(snapshot).toHaveLength(1)
expect(snapshot[0]?.files).toEqual([file])
expect(registry.checkForLSPDiagnostics()).toEqual([])
expect(registry.getPendingLSPDiagnosticCount()).toBe(0)
})
test('snapshots pending diagnostics grouped by server without consuming delivery', () => {
const typescriptFile = diagnosticFile('/repo/a.ts', ['typescript error'])
const eslintFile = diagnosticFile('/repo/b.ts', ['eslint error'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [typescriptFile],
})
registry.registerPendingLSPDiagnostic({
serverName: 'eslint',
files: [eslintFile],
})
const snapshot = registry.getPendingLSPDiagnosticsSnapshot()
expect(snapshot).toEqual([
{ serverName: 'typescript', files: [typescriptFile] },
{ serverName: 'eslint', files: [eslintFile] },
])
expect(registry.getPendingLSPDiagnosticCount()).toBe(2)
const delivered = registry.checkForLSPDiagnostics()
expect(delivered).toHaveLength(1)
expect(delivered[0]?.files).toHaveLength(2)
expect(delivered[0]?.files).toEqual(
expect.arrayContaining([typescriptFile, eslintFile]),
)
expect(registry.getPendingLSPDiagnosticCount()).toBe(0)
})
test('allows edited files to resend diagnostics when cleared by file URI', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
+99 -19
View File
@@ -21,6 +21,11 @@ export type PendingLSPDiagnostic = {
attachmentSent: boolean
}
export type LSPDiagnosticSet = {
serverName: string
files: DiagnosticFile[]
}
/**
* LSP Diagnostic Registry
*
@@ -407,7 +412,8 @@ function createDiagnosticKey(diag: {
/**
* Deduplicates diagnostics by file URI and diagnostic content.
* Also filters out diagnostics that were already delivered in previous turns.
* When filterPreviouslyDelivered is true, also filters out diagnostics that
* were already delivered in previous turns.
* Two diagnostics are considered duplicates if they have the same:
* - File URI
* - Range (start/end line and character)
@@ -417,6 +423,9 @@ function createDiagnosticKey(diag: {
*/
function deduplicateDiagnosticFiles(
allFiles: DiagnosticFile[],
options: { filterPreviouslyDelivered?: boolean } = {
filterPreviouslyDelivered: true,
},
): DeduplicationResult {
// Group diagnostics by file URI
const fileMap = new Map<string, Set<string>>()
@@ -438,7 +447,9 @@ function deduplicateDiagnosticFiles(
// Get previously delivered diagnostics for this file (for cross-turn dedup)
const previouslyDelivered =
deliveredDiagnostics.get(normalizedUri) || new Set()
options.filterPreviouslyDelivered === false
? new Set<string>()
: deliveredDiagnostics.get(normalizedUri) || new Set()
for (const diag of file.diagnostics) {
try {
@@ -569,23 +580,10 @@ function trackDeliveredDiagnostics(files: DiagnosticFile[]): void {
}
}
/**
* Get all pending LSP diagnostics that haven't been delivered yet.
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
* Marks diagnostics as sent to prevent duplicate delivery.
*
* @returns Array of pending diagnostics ready for delivery (deduplicated)
*/
export function checkForLSPDiagnostics(): Array<{
serverName: string
files: DiagnosticFile[]
}> {
const now = Date.now()
logForDebugging(
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
)
// Collect pending diagnostic files by server so storm stats remain per-server.
function collectUndeliveredDiagnosticFiles(): {
filesByServer: Map<string, DiagnosticFile[]>
diagnosticsToMark: PendingLSPDiagnostic[]
} {
const filesByServer = new Map<string, DiagnosticFile[]>()
const diagnosticsToMark: PendingLSPDiagnostic[] = []
@@ -599,6 +597,88 @@ export function checkForLSPDiagnostics(): Array<{
}
}
return { filesByServer, diagnosticsToMark }
}
/**
* Read pending LSP diagnostics without marking them delivered.
* Used by user-facing inspection surfaces that must not drain passive
* diagnostic feedback attachments.
*/
export function getPendingLSPDiagnosticsSnapshot(): LSPDiagnosticSet[] {
const now = Date.now()
logForDebugging(
`LSP Diagnostics: Snapshotting registry - ${pendingDiagnostics.size} pending`,
)
const { filesByServer } = collectUndeliveredDiagnosticFiles()
if (filesByServer.size === 0) {
return []
}
const snapshotSets: LSPDiagnosticSet[] = []
let remainingCapacity = MAX_TOTAL_DIAGNOSTICS
for (const [serverName, files] of filesByServer) {
let deduplicationResult: DeduplicationResult
try {
deduplicationResult = deduplicateDiagnosticFiles(files, {
filterPreviouslyDelivered: false,
})
} catch (error: unknown) {
const err = toError(error)
logError(
new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`),
)
deduplicationResult = { files, duplicateCount: 0 }
}
const prioritizedFiles = prioritizeDiagnosticFiles(
deduplicationResult.files,
now,
)
const limitResult = limitDiagnosticFiles(prioritizedFiles, remainingCapacity)
if (limitResult.files.length > 0) {
snapshotSets.push({
serverName,
files: limitResult.files.map(cloneDiagnosticFile),
})
}
remainingCapacity -= limitResult.deliveredCount
}
return snapshotSets
}
function cloneDiagnosticFile(file: DiagnosticFile): DiagnosticFile {
return {
uri: file.uri,
diagnostics: file.diagnostics.map(diagnostic => ({
...diagnostic,
range: {
start: { ...diagnostic.range.start },
end: { ...diagnostic.range.end },
},
})),
}
}
/**
* Get all pending LSP diagnostics that haven't been delivered yet.
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
* Marks diagnostics as sent to prevent duplicate delivery.
*
* @returns Array of pending diagnostics ready for delivery (deduplicated)
*/
export function checkForLSPDiagnostics(): LSPDiagnosticSet[] {
const now = Date.now()
logForDebugging(
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
)
const { filesByServer, diagnosticsToMark } =
collectUndeliveredDiagnosticFiles()
if (filesByServer.size === 0) {
return []
}
+1
View File
@@ -136,6 +136,7 @@ export const commands: SlashCommand[] = [
{ name: 'help', description: 'Show help and available commands', category: 'diagnostics' },
{ name: 'status', description: 'Show status including version, model, account, API connectivity, and tool statuses', category: 'diagnostics' },
{ name: 'doctor', description: 'Diagnose and verify your OpenClaude installation and settings', category: 'diagnostics' },
{ name: 'diagnostics', description: 'Show available LSP diagnostics already captured for this session', category: 'diagnostics' },
{ name: 'stats', description: 'Show your usage statistics and activity', category: 'diagnostics' },
{ name: 'insights', description: 'Generate a report analyzing your OpenClaude sessions', category: 'diagnostics' },
{ name: 'release-notes', description: 'View release notes', category: 'diagnostics' },