feat(ai): show lint details on demand in the chat

The lint tool call rendered as a bare summary line with nothing behind it.
Mark it as having details so the individual problems can be opened, and pass
the formatted output as the tool result — without it the details panel reads
"No result yet". Details stay collapsed on success, since the header already
carries the counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-20 14:52:07 +02:00
parent 2fdafdddaf
commit 88e3a41fa1
2 changed files with 57 additions and 8 deletions
@@ -245,6 +245,19 @@ vi.mock('./rawAppBundlerBridge', () => ({
}))
}))
// Monaco cannot load in the node test environment, and core.ts imports the lint
// service lazily precisely to keep it out of this import graph.
vi.mock('$lib/components/lint/headlessLint', () => ({
canLintHeadless: (lang: string) => lang === 'bun',
lintCode: vi.fn(async () => ({
errorCount: 1,
warningCount: 0,
errors: [{ startLineNumber: 2, message: "Type 'string' is not assignable to type 'number'." }],
warnings: [],
contentMismatch: false
}))
}))
vi.mock('$lib/infer', async () => ({
...(await vi.importActual<any>('$lib/infer')),
// Avoid the wasm parser in unit tests: the script deploy path infers the arg
@@ -433,6 +446,26 @@ describe('global AI tools', () => {
)
})
it('surfaces lint output as the tool result so the details panel can show it', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'u/admin/probe',
content: 'const a: number = "x"',
language: 'bun',
summary: ''
} as any)
const result = await callGlobalTool('get_lint_errors', {
kind: 'script',
path: 'u/admin/probe'
})
expect(result).toContain("Type 'string' is not assignable to type 'number'.")
expect(toolCallbacks.setToolStatus).toHaveBeenCalledWith(
'test-get_lint_errors',
expect.objectContaining({ result })
)
})
it('defaults list_runs to 30 results when no limit is given', async () => {
await callGlobalTool('list_runs', {})
expect(JobService.listJobs).toHaveBeenCalledWith(
@@ -2751,6 +2751,9 @@ export const globalTools: Tool<{}>[] = [
'get_lint_errors',
'Type-check a script, flow module, or raw app backend runnable and report its errors and warnings. Supports TypeScript, JavaScript, Python, Go, Deno and Bash.'
),
// The header carries the counts; the individual problems are worth a look but not
// worth the vertical space by default.
showDetails: true,
fn: async (ctx) => {
const parsed = getLintErrorsSchema.parse(ctx.args)
return getLintErrors(parsed, ctx)
@@ -3812,12 +3815,20 @@ async function checkAppFrontend(path: string, ctx: WriteDraftCtx): Promise<strin
const value = await loadAppValueForRead(path, workspace)
try {
await bundleRawAppDraft({ workspace, files: value.files })
toolCallbacks.setToolStatus(toolId, { content: `Frontend of app "${path}" compiles` })
return '✅ The app frontend compiles with no errors.'
const response = '✅ The app frontend compiles with no errors.'
toolCallbacks.setToolStatus(toolId, {
content: `Frontend of app "${path}" compiles`,
result: response
})
return response
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
toolCallbacks.setToolStatus(toolId, { content: `Frontend of app "${path}" failed to compile` })
return `❌ The app frontend failed to compile:\n\n${message}`
const response = `❌ The app frontend failed to compile:\n\n${message}`
toolCallbacks.setToolStatus(toolId, {
content: `Frontend of app "${path}" failed to compile`,
result: response
})
return response
}
}
@@ -3835,10 +3846,12 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise<
const { canLintHeadless, lintCode } = await import('$lib/components/lint/headlessLint')
if (!canLintHeadless(target.language)) {
const response = `Linting ${target.language} is not supported. Do not retry; verify the code by test-running it instead.`
toolCallbacks.setToolStatus(toolId, {
content: `Lint unavailable for ${target.language}`
content: `Lint unavailable for ${target.language}`,
result: response
})
return `Linting ${target.language} is not supported. Do not retry; verify the code by test-running it instead.`
return response
}
toolCallbacks.setToolStatus(toolId, { content: `Linting ${target.label}...` })
@@ -3855,8 +3868,6 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise<
: result.warningCount > 0
? `${result.warningCount} warning(s)`
: 'no issues'
toolCallbacks.setToolStatus(toolId, { content: `Linted ${target.label}: ${summary}` })
let response = formatScriptLintResult(result)
if (result.unavailableServers?.length) {
response += `\n\nNote: the ${result.unavailableServers.join(' and ')} language server${result.unavailableServers.length > 1 ? 's did' : ' did'} not respond, so some problems may not be listed. Treat a clean result as inconclusive.`
@@ -3864,6 +3875,11 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise<
if (result.contentMismatch) {
response += `\n\nNote: an editor is currently open on this code and its buffer differs from the draft. The results above are for what that editor shows.`
}
// The result has to reach the tool display, or its details panel reads "No result yet".
toolCallbacks.setToolStatus(toolId, {
content: `Linted ${target.label}: ${summary}`,
result: response
})
return response
}