From 0bbab4584fafc841a6e4fec77b78ee66cb1e1993 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Sun, 9 Aug 2026 01:48:47 +0800 Subject: [PATCH 1/4] Free the VS Code panel after Cancel, and make answers copyable Cancel cleared the in-flight state only when the engine unwound, so the composer stayed locked and the button dead for as long as that took. A late event from the abandoned turn could also paint into the turn that replaced it. An error about a result no longer held in memory was handled as a general error: it deleted the live turn's progress row, rendered its query in the wrong place, and put the message under the wrong question. Those errors now carry the id of the result whose button was clicked and land in that turn. Copy controls on the query, the explanation, a schema answer, a corrected query and each fenced block, each confirming only once the host reports the clipboard was written. A corrected query can be run directly; it re-enters through the normal ask path, so the guard and the approval step still apply. Fenced code had no CSS rule at all, which left it unstyled and made a long line scroll the whole transcript sideways. The automatic row-limit notice names the cap the engine applied rather than the raw setting, which could be above the ceiling or fractional. --- packages/vscode/CHANGELOG.md | 22 +- packages/vscode/README.md | 2 +- packages/vscode/media/chat.css | 18 +- packages/vscode/media/chat.js | 123 +++++++-- packages/vscode/media/walkthrough/provider.md | 1 + packages/vscode/package.json | 2 +- packages/vscode/src/chatView.ts | 41 ++- packages/vscode/src/engine.ts | 14 +- packages/vscode/test/chatView.test.ts | 242 ++++++++++++++++++ packages/vscode/test/engine.test.ts | 24 +- 10 files changed, 449 insertions(+), 40 deletions(-) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index ed3c284..b08ad30 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,7 +4,27 @@ All notable changes to the AskSQL VS Code extension are documented here. The for [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.7.0] - 2026-08-09 + +### Fixed +- **Cancel no longer leaves the panel unusable.** Stopping a turn cleared the in-flight state only + when the engine got round to unwinding, so the composer stayed locked and the button dead for as + long as that took. A late event from the abandoned turn can also no longer paint into the new one. +- An error about a result that is no longer held in memory lands in the turn whose button was + clicked. It used to be handled as a general error, which deleted the live turn's progress row, + rendered its query in the wrong place, and put the message under the wrong question. +- The query plan's progress row and the turn's own no longer delete each other. With approval + turned on, asking for a plan during the pause left whichever lost the race looking stalled. +- A fenced code block in an explanation is styled like the rest of the SQL. It had no rule at all, + which also made a long line scroll the whole transcript sideways. +- The automatic row limit names the cap that was applied. It printed the raw setting, so a value + above the engine's ceiling, or a fractional one, named a number that was never used. + +### Added +- **Copy controls** on the query, the explanation, a schema answer, a corrected query, and each + fenced block inside an answer. Each confirms only once the host reports the clipboard was written. +- **Run this query** on a corrected query, so a rejected query can be fixed and rerun without + copying it out. It re-enters through the normal path, so the guard and the approval step still apply. ## [0.6.1] - 2026-08-07 diff --git a/packages/vscode/README.md b/packages/vscode/README.md index d9fcb1d..9c3a0ed 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -45,7 +45,7 @@ how many appointments were booked last week? question ("appoinments") resolves to the real table instead of a refusal. - **You are in control.** Every answer shows the SQL that produced it - below the results by default, or above them with `asksql.sqlDisplay`. Require a click before anything runs - (`asksql.requireApproval`), cap rows (`asksql.maxRows`), and **Stop** a running query at any time. + (`asksql.requireApproval`), cap rows (`asksql.maxRows`), and **Cancel** a running query at any time. - **Many databases, one panel.** Keep several connections and switch between them from the panel; each answer is labelled with the database it ran against. - **Bring your own model.** A chat model you already have in VS Code (no API key), a fully local diff --git a/packages/vscode/media/chat.css b/packages/vscode/media/chat.css index 17b659f..642b9b0 100644 --- a/packages/vscode/media/chat.css +++ b/packages/vscode/media/chat.css @@ -169,7 +169,8 @@ button:focus-visible { /* SQL --------------------------------------------------------------------- */ -pre.sql { +pre.sql, +pre.md-code { margin: 0; padding: 8px; overflow-x: auto; @@ -182,6 +183,16 @@ pre.sql { white-space: pre; } +/* A fenced block sits inside prose and needs its own breathing room. */ +pre.md-code { + margin: 6px 0; +} + +/* The fence's own Copy row, kept tight against its block. */ +.md-codeacts { + margin: 0 0 8px; +} + .explain { color: var(--vscode-foreground); overflow-wrap: anywhere; @@ -375,8 +386,11 @@ button.iconbtn { padding: 4px 6px; } -button.iconbtn.ok { +/* Copy ack; a colour swap alone is invisible on a text button. */ +button.ok { color: var(--vscode-testing-iconPassed, var(--vscode-charts-green, currentColor)); + outline: 1px solid var(--vscode-testing-iconPassed, var(--vscode-charts-green, currentColor)); + outline-offset: 1px; } button:disabled { diff --git a/packages/vscode/media/chat.js b/packages/vscode/media/chat.js index 57ac498..c90222b 100644 --- a/packages/vscode/media/chat.js +++ b/packages/vscode/media/chat.js @@ -25,6 +25,7 @@ /** In-flight plan requests, mapped to the turn whose button asked for them. */ const planTurns = new Map(); let planSeq = 0; + let copySeq = 0; const el = (tag, cls, text) => { const n = document.createElement(tag); @@ -60,9 +61,14 @@ i++; while (i < lines.length && !/^\s*```/u.test(lines[i])) code.push(lines[i++]); i++; // skip the closing fence + const text = code.join('\n'); const pre = el('pre', 'md-code'); - pre.textContent = code.join('\n'); + pre.textContent = text; box.appendChild(pre); + // Copy only: a prose fence has passed neither the guard nor the unknown-name checks. + const acts = el('div', 'actions md-codeacts'); + acts.appendChild(copyBtn('Copy', () => text)); + box.appendChild(acts); continue; } const bullet = /^\s*[-*]\s+/u.test(lines[i]); @@ -100,6 +106,15 @@ return svg; } + /** A copy button for a block of text. The host's ack finds the button by its id. */ + function copyBtn(label, getText) { + const b = el('button', 'secondary', label); + const copyId = 'c' + ++copySeq; + b.dataset.copy = copyId; + b.addEventListener('click', () => vscode.postMessage({ type: 'copyText', text: getText(), copyId })); + return b; + } + const nearBottom = () => $log.scrollHeight - $log.scrollTop - $log.clientHeight < 80; // Soft scroll: follow new content only when the user is already at the bottom. const scroll = () => { @@ -122,10 +137,13 @@ applyLock(); } - /** Drop the transient progress line once real content arrives. */ + /** The progress row of a plan request, or the turn's own. The two are independent. */ + const progressSel = (planId) => (planId ? '.progress[data-plan="' + planId + '"]' : '.progress:not([data-plan])'); + + /** Drop the transient progress line once real content arrives. Plan progress is not ours to drop. */ function clearProgress() { if (!turn) return; - const p = turn.querySelector('.progress'); + const p = turn.querySelector(progressSel()); if (p) p.remove(); } @@ -315,11 +333,18 @@ lastSqlBlock = { turn: myTurn, el: block }; block.appendChild(el('pre', 'sql', sql)); if (m.explanation) block.appendChild(renderMarkdown('explain', m.explanation)); - if (m.autoLimited) block.appendChild(el('div', 'note', 'A row limit was added automatically.')); + if (m.autoLimited) { + // The host names the cap it applied. + const limit = typeof m.rowLimit === 'number' && m.rowLimit > 0 ? m.rowLimit : null; + const text = limit ? `A row limit of ${limit} was added automatically.` : 'A row limit was added automatically.'; + block.appendChild(el('div', 'note', text)); + } const actions = el('div', 'actions'); const open = el('button', 'secondary', 'Open SQL in editor'); open.addEventListener('click', () => vscode.postMessage({ type: 'openSql', sql })); actions.appendChild(open); + actions.appendChild(copyBtn('Copy SQL', () => sql)); + if (m.explanation) actions.appendChild(copyBtn('Copy explanation', () => m.explanation)); // A query plan comes from the database, not the model, so it is a button rather than a question. const plan = el('button', 'secondary', 'Explain plan'); plan.addEventListener('click', () => { @@ -441,10 +466,23 @@ } if (m.type === 'copied') { - const btn = $log.querySelector('button.iconbtn[data-result="' + m.resultId + '"]'); + const btn = m.copyId + ? $log.querySelector('button[data-copy="' + m.copyId + '"]') + : $log.querySelector('button.iconbtn[data-result="' + m.resultId + '"]'); if (btn) { btn.classList.add('ok'); - setTimeout(() => btn.classList.remove('ok'), 1000); + // The icon button has no label to swap, only the tint. + if (!btn.classList.contains('iconbtn') && btn.dataset.label === undefined) { + btn.dataset.label = btn.textContent; + btn.textContent = 'Copied'; + } + setTimeout(() => { + btn.classList.remove('ok'); + if (btn.dataset.label !== undefined) { + btn.textContent = btn.dataset.label; + delete btn.dataset.label; + } + }, 1000); } return; } @@ -463,8 +501,12 @@ } if (m.type === 'cancelled') { + // turnEnd can be seconds away while a request is still in flight. clearProgress(); if (turn) turn.appendChild(el('div', 'note', 'Cancelled.')); + for (const a of $log.querySelectorAll('.approval')) a.remove(); + setBusy(false); + $q.focus(); return; } @@ -492,9 +534,12 @@ t = planTurns.get(m.planId); if (!t) return; } - const p = t.querySelector('.progress'); + const p = t.querySelector(progressSel(m.planId)); if (p) p.remove(); - t.appendChild(el('div', 'progress', m.label)); + const row = el('div', 'progress', m.label); + // Tagged, so a plan's progress and the turn's own progress cannot replace each other. + if (m.planId) row.dataset.plan = m.planId; + t.appendChild(row); scroll(); return; } @@ -540,6 +585,8 @@ const actions = el('div', 'actions'); // Bind this turn's result id, so the buttons act on this turn's rows. const rid = m.resultId; + // A result-store error posts back this id to find the turn. + actions.dataset.result = rid; const copy = el('button', 'secondary iconbtn'); copy.title = 'Copy table with headers'; copy.setAttribute('aria-label', 'Copy table with headers'); @@ -588,7 +635,7 @@ planTurns.delete(m.planId); if (!t) return; } - const p = t.querySelector('.progress'); + const p = t.querySelector(progressSel(m.planId)); if (p) p.remove(); t.appendChild(el('div', 'note', 'Query plan, straight from the database:')); t.appendChild(renderTable(m.columns, m.rows)); @@ -608,22 +655,26 @@ 'div', 'note', m.isSchemaChange - ? 'Proposed names not in your current schema: ' + names + '. AskSQL is read-only and ran nothing.' + ? 'Proposed names not in your current schema: ' + names + '.' : 'Heads up: this mentioned names not in your schema (' + names + '), so treat those with caution.', ), ); } - // The query in a prose answer is the same artifact as a generated one, so it gets the same action. + const saActions = el('div', 'actions'); + saActions.appendChild(copyBtn('Copy answer', () => m.answer)); + // The query in a prose answer is the same artifact as a generated one. if (m.proposedSql) { - const actions = el('div', 'actions'); const open = el('button', 'secondary', 'Open SQL in editor'); open.addEventListener('click', () => vscode.postMessage({ type: 'openSql', sql: m.proposedSql })); - actions.appendChild(open); - turn.appendChild(actions); + saActions.appendChild(open); + saActions.appendChild(copyBtn('Copy SQL', () => m.proposedSql)); + } + turn.appendChild(saActions); + if (!String(m.answer ?? '').includes('AskSQL is read-only')) { + turn.appendChild( + el('div', 'note', 'Generated from your schema by the model - no query was run, so treat it as guidance.'), + ); } - turn.appendChild( - el('div', 'note', 'Generated from your schema by the model - no query was run, so treat it as guidance.'), - ); scroll(); return; } @@ -633,13 +684,38 @@ const t = planTurns.get(m.planId); planTurns.delete(m.planId); if (!t) return; - const p = t.querySelector('.progress'); + const p = t.querySelector(progressSel(m.planId)); if (p) p.remove(); t.appendChild(el('div', 'err', m.message)); scroll(); return; } + // A result-store failure belongs to the turn whose button was clicked, not the live turn. + if (m.type === 'error' && m.resultId) { + const acts = $log.querySelector('.actions[data-result="' + m.resultId + '"]'); + const t = acts && acts.closest('.turn'); + if (!t) return; + // Repeat clicks replace the banner. + const prior = t.querySelector('.err.result-gone'); + if (prior) prior.remove(); + t.appendChild(el('div', 'err result-gone', m.message)); + scroll(); + return; + } + + // A copy belongs to the turn holding the clicked button, not the live turn. + if (m.type === 'error' && m.copyId) { + const btn = $log.querySelector('button[data-copy="' + m.copyId + '"]'); + const t = btn && btn.closest('.turn'); + if (!t) return; + const prior = t.querySelector('.err.copy-failed'); + if (prior) prior.remove(); + t.appendChild(el('div', 'err copy-failed', m.message)); + scroll(); + return; + } + if (m.type === 'error') { clearProgress(); // A correction replaces the rejected query; without one, the failed query is shown above the error. @@ -654,18 +730,21 @@ const box = el('div', m.guard ? 'err guard' : 'err', m.message); turn.appendChild(box); if (m.guard) { - turn.appendChild( - el('div', 'note', 'AskSQL only runs read-only queries, so this was refused before it reached the database.'), - ); + turn.appendChild(el('div', 'note', 'This was refused before it reached the database.')); } if (m.suggestedSql) { turn.appendChild(el('div', 'note', 'Corrected to match your schema:')); turn.appendChild(el('pre', 'sql', m.suggestedSql)); const acts = el('div', 'actions'); - const open = el('button', 'secondary', 'Open SQL in editor'); const sql = m.suggestedSql; + // Running it asks again with this query as the question. + const run = el('button', null, 'Run this query'); + run.addEventListener('click', () => ask(sql)); + acts.appendChild(run); + const open = el('button', 'secondary', 'Open SQL in editor'); open.addEventListener('click', () => vscode.postMessage({ type: 'openSql', sql })); acts.appendChild(open); + acts.appendChild(copyBtn('Copy SQL', () => sql)); turn.appendChild(acts); } if (m.action) { diff --git a/packages/vscode/media/walkthrough/provider.md b/packages/vscode/media/walkthrough/provider.md index f6b3148..d7347f1 100644 --- a/packages/vscode/media/walkthrough/provider.md +++ b/packages/vscode/media/walkthrough/provider.md @@ -5,6 +5,7 @@ AskSQL brings your own model, so nothing is sent to a service you did not config - **Ollama** runs fully local and needs no API key. - **Groq** and **NVIDIA** offer generous free tiers. - **OpenAI**, **Anthropic** and **Google** work with your own key. +- **Azure OpenAI** needs your own key plus the resource name from your endpoint. - **OpenAI-compatible** covers LM Studio, vLLM, OpenRouter, Together, or any gateway. Run **Select AI Provider** to pick the provider, enter its API key (stored in the OS keychain), and choose a model, all in one flow. Official endpoints are pre-filled, so you only set a base URL for a custom endpoint. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index c203829..78b631b 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "AskSQL", "description": "AI database chat: ask in plain language, review the query, get answers. Read-only by design, bring your own model.", - "version": "0.6.1", + "version": "0.7.0", "publisher": "RahulMahadik", "license": "Apache-2.0", "pricing": "Free", diff --git a/packages/vscode/src/chatView.ts b/packages/vscode/src/chatView.ts index 4ded3a2..9d6b9e7 100644 --- a/packages/vscode/src/chatView.ts +++ b/packages/vscode/src/chatView.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import { randomBytes } from 'node:crypto'; import { AskSqlError, + resolveGuardPolicy, type AskSqlEngine, type GuardVerdict, type ResultSet, @@ -175,21 +176,27 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } // The SQL travels WITH the click, so an old turn's button opens that turn's query. if (m.type === 'openSql') void vscode.commands.executeCommand('asksql.openSqlInEditor', String(m.sql ?? '')); + // The resultId is echoed on the failure, so the banner lands in the right turn. if (m.type === 'exportCsv') { - const res = this.results.get(String(m.resultId ?? '')); + const resultId = String(m.resultId ?? ''); + const res = this.results.get(resultId); if (res) void vscode.commands.executeCommand('asksql.exportCsv', res); - else this.post({ type: 'error', message: RESULT_GONE }); + else this.post({ type: 'error', message: RESULT_GONE, resultId }); } if (m.type === 'copy') { - const res = this.results.get(String(m.resultId ?? '')); - if (res) void this.copyResult(res, String(m.resultId ?? '')); - else this.post({ type: 'error', message: RESULT_GONE }); + const resultId = String(m.resultId ?? ''); + const res = this.results.get(resultId); + if (res) void this.copyResult(res, resultId); + else this.post({ type: 'error', message: RESULT_GONE, resultId }); } if (m.type === 'openResult') { - const res = this.results.get(String(m.resultId ?? '')); + const resultId = String(m.resultId ?? ''); + const res = this.results.get(resultId); if (res) void this.openResultInEditor(res); - else this.post({ type: 'error', message: RESULT_GONE }); + else this.post({ type: 'error', message: RESULT_GONE, resultId }); } + // A block the panel already rendered (SQL, an explanation). + if (m.type === 'copyText' && typeof m.text === 'string') void this.copyText(m.text, String(m.copyId ?? '')); if (m.type === 'plan') void this.plan( String(m.sql ?? ''), @@ -639,6 +646,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } + // Resolving the engine can take a moment, before the first stage arrives. + this.post({ type: 'progress', label: 'Getting ready' }); const engine = await this.engineFor(conn.id); let answer; try { @@ -652,6 +661,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { .map((h) => ({ question: h.question, sql: h.sql })), signal: ac.signal, onEvent: (e) => { + // A cancelled turn no longer owns the log. + if (ac.signal.aborted) return; if (e.type === 'stage') this.post({ type: 'progress', label: STAGE_LABEL[e.stage] ?? e.stage }); else if (e.type === 'warning') turnWarnings.push(e.message); }, @@ -709,6 +720,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { connectionId: conn.id, explanation: answer.explanation ?? '', autoLimited: answer.guard.autoLimited, + // The cap the engine was built with: the core policy floors and caps, so the raw setting can differ. + rowLimit: resolveGuardPolicy({ maxRows: cfg.get('maxRows') ?? 100 }).maxRows, placement: approval ? 'before' : (cfg.get('sqlDisplay') ?? 'after'), needsApproval: approval, ...(approvalId ? { approvalId } : {}), @@ -829,7 +842,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.post({ type: 'copied', resultId }); } catch (err) { log.error('copy to clipboard failed', err); - this.post({ type: 'error', message: 'Could not copy the result to the clipboard.' }); + this.post({ type: 'error', message: 'Could not copy the result to the clipboard.', resultId }); + } + } + + /** Copy a rendered block of text, acked with the id of the button that asked. */ + private async copyText(text: string, copyId: string): Promise { + try { + await vscode.env.clipboard.writeText(text); + this.post({ type: 'copied', copyId }); + } catch (err) { + log.error('copy to clipboard failed', err); + // Correlated: an uncorrelated error tears down whichever turn is live. + this.post({ type: 'error', message: 'Could not copy to the clipboard.', copyId }); } } diff --git a/packages/vscode/src/engine.ts b/packages/vscode/src/engine.ts index 5fc791b..020dfc6 100644 --- a/packages/vscode/src/engine.ts +++ b/packages/vscode/src/engine.ts @@ -17,7 +17,12 @@ import { type ResultSet, type SchemaCatalog, } from '@asksql/core'; -import { createMongoAskSql, type MongoAskEngine, type MongoConnector } from '@asksql/core/mongo'; +import { + createMongoAskSql, + resolveMongoGuardPolicy, + type MongoAskEngine, + type MongoConnector, +} from '@asksql/core/mongo'; import { PostgresConnector } from '@asksql/postgres'; import { MysqlConnector } from '@asksql/mysql'; import { SqliteConnector } from '@asksql/sqlite'; @@ -485,7 +490,8 @@ export class EngineManager { const engine = createMongoAskSql({ connector, model: resolved, - policy: { maxRows: cfg().get('maxRows') ?? 1000 }, + // Mirrors package.json's declared asksql.maxRows default. + policy: resolveMongoGuardPolicy({ maxRows: cfg().get('maxRows') ?? 100 }), }); this.mongoEngines.set(cacheKey, engine); return engine; @@ -643,7 +649,7 @@ export class EngineManager { const engine = createAskSql({ connectors, model: resolved, - policy: { maxRows: cfg().get('maxRows') ?? 1000 }, + policy: { maxRows: cfg().get('maxRows') ?? 100 }, pruner: { maxSchemaTokens: cfg().get('maxSchemaTokens') ?? 6000 }, // The setting that lets the connector sample values also tells the engine it may prompt with them. allowDataInPrompt: cfg().get('sampleColumnValues') ?? false, @@ -688,7 +694,7 @@ export class EngineManager { throw new UserFacingError('This database cannot show a query plan.'); } // The SQL arrives over the webview channel: every string reaching a database passes the guard first. - const maxRows = cfg().get('maxRows') ?? 1000; + const maxRows = cfg().get('maxRows') ?? 100; const verdict = guardSql({ sql, dialect: conn.dialect, policy: { mode: 'read-only', maxRows } }); if (!verdict.allowed) { throw new UserFacingError(`That query cannot be explained: ${verdict.reason ?? 'it is not a read-only query'}.`); diff --git a/packages/vscode/test/chatView.test.ts b/packages/vscode/test/chatView.test.ts index 1ff7ea9..6ffe6da 100644 --- a/packages/vscode/test/chatView.test.ts +++ b/packages/vscode/test/chatView.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { JSDOM } from 'jsdom'; import { AskSqlError } from '@asksql/core'; import { resetVscodeMock, setInspect, setConfig, commands, window, workspace, env, lm, Uri } from './vscode-mock.js'; import { ChatViewProvider } from '../src/chatView.js'; @@ -6,6 +9,8 @@ import { UserFacingError } from '../src/errors.js'; const tick = () => new Promise((r) => setTimeout(r, 0)); +const chatSource = readFileSync(fileURLToPath(new URL('../media/chat.js', import.meta.url)), 'utf8'); + /** A fake WebviewView that records posted messages and exposes the message sink. */ function fakeView() { const posted: Record[] = []; @@ -311,6 +316,76 @@ describe('ask - model path', () => { expect(answer.run).toHaveBeenCalled(); }); + it('sends the row cap alongside an auto-limited query, so the panel can name it', async () => { + const answer = { + sql: 'select 1', + explanation: '', + guard: { autoLimited: true }, + run: vi.fn(async () => oneRowResult), + }; + const engines = fakeEngines({ forConfiguredModel: vi.fn(async () => answeringEngine(answer)) }); + setConfig({ requireApproval: false, sqlDisplay: 'after', maxRows: 250 }); + const p = new ChatViewProvider(fakeCtx(), engines); + const { view, posted } = fakeView(); + p.resolveWebviewView(view); + await (p as unknown as { ask: (t: string, c?: string) => Promise }).ask('how many orders'); + const sql = posted.find((m) => m.type === 'sql') as { autoLimited: boolean; rowLimit: number }; + expect(sql.autoLimited).toBe(true); + expect(sql.rowLimit).toBe(250); + }); + + it('names the capped row limit, not a setting above the engine cap', async () => { + const answer = { + sql: 'select 1', + explanation: '', + guard: { autoLimited: true }, + run: vi.fn(async () => oneRowResult), + }; + const engines = fakeEngines({ forConfiguredModel: vi.fn(async () => answeringEngine(answer)) }); + setConfig({ requireApproval: false, sqlDisplay: 'after', maxRows: 250_000 }); + const p = new ChatViewProvider(fakeCtx(), engines); + const { view, posted } = fakeView(); + p.resolveWebviewView(view); + await (p as unknown as { ask: (t: string, c?: string) => Promise }).ask('how many orders'); + expect((posted.find((m) => m.type === 'sql') as { rowLimit: number }).rowLimit).toBe(100_000); + }); + + it('names the floored row limit for a fractional setting', async () => { + const answer = { + sql: 'select 1', + explanation: '', + guard: { autoLimited: true }, + run: vi.fn(async () => oneRowResult), + }; + const engines = fakeEngines({ forConfiguredModel: vi.fn(async () => answeringEngine(answer)) }); + setConfig({ requireApproval: false, sqlDisplay: 'after', maxRows: 10.5 }); + const p = new ChatViewProvider(fakeCtx(), engines); + const { view, posted } = fakeView(); + p.resolveWebviewView(view); + await (p as unknown as { ask: (t: string, c?: string) => Promise }).ask('how many orders'); + expect((posted.find((m) => m.type === 'sql') as { rowLimit: number }).rowLimit).toBe(10); + }); + + it('posts progress before the first engine stage, so the panel is never silent', async () => { + const answer = { + sql: 'select 1', + explanation: '', + guard: { autoLimited: false }, + run: vi.fn(async () => oneRowResult), + }; + const engines = fakeEngines({ forConfiguredModel: vi.fn(async () => answeringEngine(answer)) }); + setConfig({ requireApproval: false }); + const p = new ChatViewProvider(fakeCtx(), engines); + const { view, posted } = fakeView(); + p.resolveWebviewView(view); + await (p as unknown as { ask: (t: string, c?: string) => Promise }).ask('how many orders'); + const first = posted.findIndex((m) => m.type === 'progress'); + // 'Writing SQL' is the engine's llm stage, relayed as progress. + const stage = posted.findIndex((m) => m.type === 'progress' && m.label === 'Writing SQL'); + expect(first).toBeGreaterThan(-1); + expect(stage).toBeGreaterThan(first); + }); + it('falls back to a schema answer when SQL fails and the setting is on', async () => { const engine = { ask: vi.fn(async () => { @@ -492,6 +567,7 @@ describe('ask - model path', () => { expect(mongo.execute).toHaveBeenCalled(); }); + it('surfaces a recorded build failure instead of a generic error', async () => { const engines = fakeEngines({ forConfiguredModel: vi.fn(async () => { @@ -644,6 +720,47 @@ describe('message routing', () => { expect(gone.length).toBe(3); }); + it('carries the resultId on a gone-result error, so it lands in that turn', () => { + const p = new ChatViewProvider(fakeCtx(), fakeEngines()); + const { view, posted, send } = fakeView(); + p.resolveWebviewView(view); + send({ type: 'exportCsv', resultId: 'r7' }); + send({ type: 'openResult', resultId: 'r7' }); + send({ type: 'copy', resultId: 'r7' }); + const gone = posted.filter((m) => m.type === 'error'); + expect(gone.length).toBe(3); + expect(gone.every((m) => m.resultId === 'r7')).toBe(true); + }); + + it('copies rendered text and acks with the same copyId', async () => { + const p = new ChatViewProvider(fakeCtx(), fakeEngines()); + const { view, posted, send } = fakeView(); + p.resolveWebviewView(view); + send({ type: 'copyText', text: 'select 1', copyId: 'c3' }); + await tick(); + expect(env.clipboard.writeText).toHaveBeenCalledWith('select 1'); + expect(posted.some((m) => m.type === 'copied' && m.copyId === 'c3')).toBe(true); + }); + + it('reports an error when copying rendered text fails', async () => { + const p = new ChatViewProvider(fakeCtx(), fakeEngines()); + const { view, posted, send } = fakeView(); + p.resolveWebviewView(view); + env.clipboard.writeText.mockRejectedValueOnce(new Error('denied')); + send({ type: 'copyText', text: 'select 1', copyId: 'c4' }); + await tick(); + expect(posted.some((m) => m.type === 'copied')).toBe(false); + expect(posted.some((m) => m.type === 'error' && /Could not copy/.test(String(m.message)))).toBe(true); + }); + + it('ignores a copyText whose text is not a string', () => { + const p = new ChatViewProvider(fakeCtx(), fakeEngines()); + const { view, send } = fakeView(); + p.resolveWebviewView(view); + send({ type: 'copyText', text: { sql: 'select 1' }, copyId: 'c5' }); + expect(env.clipboard.writeText).not.toHaveBeenCalled(); + }); + it('stop aborts a live turn once', async () => { let resolveRun: (v: unknown) => void = () => {}; const answer = { @@ -753,3 +870,128 @@ describe('pickModel', () => { vi.unstubAllGlobals(); }); }); + +/** The panel's own HTML plus the real media/chat.js under jsdom, host stubbed, with a turn already open. */ +function panel() { + const p = new ChatViewProvider(fakeCtx(), fakeEngines()); + const { view } = fakeView(); + p.resolveWebviewView(view); + const html = (view as unknown as { webview: { html: string } }).webview.html; + const dom = new JSDOM(html, { runScripts: 'outside-only' }); + const sent: Record[] = []; + (dom.window as unknown as { acquireVsCodeApi: () => unknown }).acquireVsCodeApi = () => ({ + postMessage: (m: Record) => sent.push(m), + }); + dom.window.eval(chatSource); + const doc = dom.window.document; + const post = (m: Record): void => { + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { data: m })); + }; + const button = (label: string, scope: ParentNode = doc): HTMLButtonElement | undefined => + [...scope.querySelectorAll('button')].find((b) => b.textContent === label); + post({ type: 'turnStart', question: 'how many orders', connection: 'DB One' }); + return { doc, sent, post, button, logText: () => doc.getElementById('log')?.textContent ?? '' }; +} + +describe('webview rendering', () => { + it('gives a fenced block in an explanation its own Copy, and nothing that runs it', () => { + const { doc, sent, post, button } = panel(); + post({ + type: 'sql', + sql: 'select 1', + explanation: 'Try this:\n```\nselect id from orders\n```\nIt reads one column.', + placement: 'before', + }); + const box = doc.querySelector('.explain') as HTMLElement; + expect(box.querySelectorAll('pre.md-code').length).toBe(1); + // Copy only: a prose fence has not passed the plan/run paths. + expect([...box.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Copy']); + button('Copy', box)!.click(); + expect(sent.some((m) => m.type === 'copyText' && m.text === 'select id from orders')).toBe(true); + }); + + it('gives a schema answer fence the same Copy', () => { + const { doc, sent, post, button } = panel(); + post({ type: 'schemaAnswer', answer: 'Like so:\n```\nselect 2\n```', unknownReferences: [] }); + const box = doc.querySelector('.explain') as HTMLElement; + button('Copy', box)!.click(); + expect(sent.some((m) => m.type === 'copyText' && m.text === 'select 2')).toBe(true); + }); + + it('runs a corrected query through the ask path', () => { + const { sent, post, button } = panel(); + post({ type: 'error', message: 'the query failed', suggestedSql: 'select name from orders' }); + post({ type: 'turnEnd' }); + button('Run this query')!.click(); + expect(sent.at(-1)).toMatchObject({ type: 'ask', text: 'select name from orders' }); + }); + + it('states a read-only refusal once, in the engine wording', () => { + const { post, logText } = panel(); + post({ + type: 'error', + guard: true, + message: 'Blocked for safety: this statement is not allowed in read-only mode.', + }); + const text = logText(); + expect(text).toContain('Blocked for safety: this statement is not allowed in read-only mode.'); + expect(text.match(/read-only/gu)?.length).toBe(1); + expect(text).toContain('This was refused before it reached the database.'); + }); + + it('names the row cap the host applied', () => { + const { doc, post } = panel(); + post({ type: 'sql', sql: 'select 1', autoLimited: true, rowLimit: 250, placement: 'before' }); + expect(doc.querySelector('.sqlblock .note')?.textContent).toBe('A row limit of 250 was added automatically.'); + }); + + it("shows the host's copy ack on the button that was clicked, then restores its label", async () => { + const { sent, post, button } = panel(); + post({ type: 'sql', sql: 'select 1', explanation: 'reads one row', placement: 'before' }); + const copy = button('Copy SQL')!; + const other = button('Copy explanation')!; + copy.click(); + post({ type: 'copied', copyId: (sent.at(-1) as { copyId: string }).copyId }); + expect(copy.textContent).toBe('Copied'); + expect(copy.classList.contains('ok')).toBe(true); + // The ack is correlated, not broadcast. + expect(other.textContent).toBe('Copy explanation'); + expect(other.classList.contains('ok')).toBe(false); + await new Promise((r) => setTimeout(r, 1100)); + expect(copy.textContent).toBe('Copy SQL'); + expect(copy.classList.contains('ok')).toBe(false); + }); + + it('confirms a fence Copy the same way', () => { + const { doc, sent, post, button } = panel(); + post({ type: 'sql', sql: 'select 1', explanation: 'Try:\n```\nselect 2\n```', placement: 'before' }); + const box = doc.querySelector('.explain') as HTMLElement; + const copy = button('Copy', box)!; + copy.click(); + post({ type: 'copied', copyId: (sent.at(-1) as { copyId: string }).copyId }); + expect(copy.textContent).toBe('Copied'); + }); + + it('styles the copy ack on text buttons, not only icon buttons', () => { + const css = readFileSync(fileURLToPath(new URL('../media/chat.css', import.meta.url)), 'utf8'); + const dom = new JSDOM(``); + const btn = dom.window.document.querySelector('button')!; + const rules = [...(dom.window.document.styleSheets[0].cssRules as unknown as CSSRule[])]; + const hit = rules.some((r) => { + const sel = (r as CSSStyleRule).selectorText; + if (typeof sel !== 'string' || !sel.includes('.ok')) return false; + try { + return btn.matches(sel); + } catch { + return false; + } + }); + expect(hit).toBe(true); + }); + + it('keeps the generic notice when the message carries no number', () => { + const { doc, post } = panel(); + post({ type: 'sql', sql: 'select 1', autoLimited: true, placement: 'before' }); + expect(doc.querySelector('.sqlblock .note')?.textContent).toBe('A row limit was added automatically.'); + }); +}); diff --git a/packages/vscode/test/engine.test.ts b/packages/vscode/test/engine.test.ts index 2ae4f8a..822df47 100644 --- a/packages/vscode/test/engine.test.ts +++ b/packages/vscode/test/engine.test.ts @@ -69,8 +69,13 @@ vi.mock('node:sqlite', () => ({ const { fakeMongoEngine } = vi.hoisted(() => ({ fakeMongoEngine: { ask: vi.fn(), execute: vi.fn(), invalidateCatalog: vi.fn() }, })); -vi.mock('@asksql/core/mongo', () => ({ createMongoAskSql: vi.fn(() => fakeMongoEngine) })); +// Only the factory is faked; resolveMongoGuardPolicy stays real. +vi.mock('@asksql/core/mongo', async (importOriginal) => ({ + ...(await importOriginal()), + createMongoAskSql: vi.fn(() => fakeMongoEngine), +})); +import { createMongoAskSql } from '@asksql/core/mongo'; import { resetVscodeMock, setInspect, @@ -455,6 +460,23 @@ describe('EngineManager mongo engine path', () => { expect(await mgr.forChatModelMongo(lm, 'mo')).toBe(fakeMongoEngine); }); + // The maxRows setting is a hand-edited number. + it.each([ + { setting: 12.5, expected: 12 }, + { setting: 200_000, expected: 100_000 }, + ])('clamps a maxRows of $setting to $expected in the mongo engine policy', async ({ setting, expected }) => { + vi.mocked(createMongoAskSql).mockClear(); + const secrets = createSecretStorage(); + await storeConnectionString(secrets as never, 'mo', 'mongodb://h/db', 'user'); + setInspect('connections', { global: [{ id: 'mo', name: 'M', engine: 'mongodb', database: 'shop' }] }); + setConfig({ provider: 'ollama', model: 'qwen', maxRows: setting }); + const mgr = new EngineManager(secrets as never); + await mgr.forConfiguredModelMongo('mo'); + expect(vi.mocked(createMongoAskSql)).toHaveBeenCalledWith( + expect.objectContaining({ policy: expect.objectContaining({ maxRows: expected }) }), + ); + }); + it('invalidateCatalogs clears mongo engines too', async () => { const secrets = createSecretStorage(); await storeConnectionString(secrets as never, 'mo', 'mongodb://h/db', 'user'); From 3e7cb1be3772d7cf3c64bd2c495c51d0543a3a0f Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Sun, 9 Aug 2026 02:08:32 +0800 Subject: [PATCH 2/4] Clamp the MongoDB row cap, and stop reporting placeholders as missing names A maxRows that was fractional, zero or negative went straight into $limit, which MongoDB rejects outright, so the query failed rather than returning fewer rows. Above the ceiling it was injected unclamped while the surrounding warning named the capped number. Both engines now resolve the cap through one shared function, so the prompt, the injected limit and the warning agree. The grounding floor treated anything in backticks as an identifier, so `?`, a date, or `:param` were reported as names missing from the schema. Hyphens are legal inside backticks and are still checked. The adapters peer-depend on @asksql/core rather than depending on it. A consumer pinned to a different core got a second copy installed under the adapter, so instanceof AskSqlError silently returned false for them. The range is >= and not ^: a caret on a 0.x package excludes the next minor, which would major-bump every adapter on a core release. --- .changeset/core-as-peer-dependency.md | 21 ++++ .changeset/mongo-row-cap-and-grounding.md | 20 ++++ docs/FAQ.md | 10 +- packages/core/src/grounding.ts | 5 + packages/core/src/guard.ts | 10 +- packages/core/src/mongo/engine.ts | 5 +- packages/core/src/mongo/guard.ts | 14 ++- packages/core/src/mongo/index.ts | 1 + packages/core/src/row-cap.ts | 14 +++ packages/core/test/mongo-row-cap.test.ts | 113 ++++++++++++++++++ .../core/test/scope-grounding-edges.test.ts | 26 ++++ tests/bundle-size.test.ts | 3 +- tests/peer-install-conflict.test.ts | 100 ++++++++++++++++ tests/peer-ranges.test.ts | 93 +++++++++++++- tools/nl-e2e.mjs | 41 +++++-- 15 files changed, 448 insertions(+), 28 deletions(-) create mode 100644 .changeset/core-as-peer-dependency.md create mode 100644 .changeset/mongo-row-cap-and-grounding.md create mode 100644 packages/core/src/row-cap.ts create mode 100644 packages/core/test/mongo-row-cap.test.ts create mode 100644 tests/peer-install-conflict.test.ts diff --git a/.changeset/core-as-peer-dependency.md b/.changeset/core-as-peer-dependency.md new file mode 100644 index 0000000..d37ce6e --- /dev/null +++ b/.changeset/core-as-peer-dependency.md @@ -0,0 +1,21 @@ +--- +'@asksql/duckdb': minor +'@asksql/mcp': minor +'@asksql/mongodb': minor +'@asksql/mysql': minor +'@asksql/oracle': minor +'@asksql/postgres': minor +'@asksql/react': minor +'@asksql/server': minor +'@asksql/sqlite': minor +--- + +Depend on `@asksql/core` as a peer rather than a regular dependency. As a regular dependency, a +consumer pinned to a different core minor got a second copy of core installed under the connector +instead of a resolution error. Structural types survive that; identity does not, so +`error instanceof AskSqlError` was false for every error the connector threw and consumer error +handling silently stopped matching. The peer range is `>=0.6.0`, so npm and pnpm install one shared +core and report a real conflict when the consumer's pin cannot satisfy it. + +Yarn (classic and berry) and npm with `legacy-peer-deps` do not install peers, so on those +`@asksql/core` must now be installed explicitly alongside the package. diff --git a/.changeset/mongo-row-cap-and-grounding.md b/.changeset/mongo-row-cap-and-grounding.md new file mode 100644 index 0000000..6496564 --- /dev/null +++ b/.changeset/mongo-row-cap-and-grounding.md @@ -0,0 +1,20 @@ +--- +'@asksql/core': patch +'@asksql/react': minor +--- + +Clamp the MongoDB row cap the way the SQL side already did. A `maxRows` that was fractional, zero or +negative was passed straight into `$limit`, which MongoDB rejects outright, so the query failed +rather than returning fewer rows; a value above the engine's ceiling was injected unclamped while +the surrounding warning text named the capped number. Both engines now resolve the cap through one +shared function, so the prompt, the injected limit and the warning always name the same figure. + +Stop reporting a backticked placeholder as a name missing from your schema. Backticks wrap more than +identifiers, so `` `?` ``, a date, or `:param` were each reported as a table or column that does not +exist. Hyphenated names, which are legal inside backticks, are still checked. + +React: copy controls on explanations, schema answers, the query plan and the result grid; the +model's output is shown as it streams; the thread only follows new content when you are already at +the bottom; a schema answer no longer renders a red error while it is still being written; truncated +cells carry their full value; and `maxRows` takes effect on the next question rather than when the +connection changes. diff --git a/docs/FAQ.md b/docs/FAQ.md index 005f30b..69b894c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -43,9 +43,11 @@ new DuckDbConnector({ id: 'book', name: 'Workbook', files: [ ### What file types and sizes can it handle? -Five formats: **CSV**, **JSON**, **NDJSON**, **Parquet**, and **Excel** (`.xlsx` / `.xls`). The -format is inferred from the extension, or you can set `format` explicitly. You can register as -many files as you like - there is no file-count limit, and each becomes its own joinable table. +Six formats: **CSV**, **JSON**, **NDJSON**, **Parquet**, **Excel** (`.xlsx` / `.xls`), and a +portable **`.sql`** dump (its CREATE TABLE + INSERT statements are run and the tables they build +become queryable). The format is inferred from the extension, or you can set `format` explicitly. +You can register as many files as you like - there is no file-count limit, and each becomes its +own joinable table. There is **no fixed size cap** in AskSQL itself. In the browser the file is streamed into DuckDB-WASM (bounded by the tab's available memory, or persistent OPFS storage if enabled), and @@ -251,7 +253,7 @@ entirely. The guard still enforces read-only regardless of what any prompt says. ### Is it production-ready? -It is an early (pre-1.0; `@asksql/core` is at `0.5.x`) but functional release: the pipeline +It is an early (pre-1.0; `@asksql/core` is at `0.6.x`) but functional release: the pipeline (schema to SQL to guard to execute), the safety guard, the six database adapters, the server sidecar, the React UI, and the MCP server are all working and tested against live databases and multiple providers. Treat diff --git a/packages/core/src/grounding.ts b/packages/core/src/grounding.ts index 1f85295..07f5948 100644 --- a/packages/core/src/grounding.ts +++ b/packages/core/src/grounding.ts @@ -231,6 +231,9 @@ export interface GroundingOptions { readonly documentStyle?: boolean; } +/** An identifier, optionally schema-qualified. Placeholders, literals and operators do not match. */ +const IDENTIFIER_SHAPE = /^[a-z_][a-z0-9_$-]*(?:\.[a-z_][a-z0-9_$-]*)*$/i; + export function unknownReferencesInProse( answer: string, catalog: SchemaCatalog, @@ -254,6 +257,8 @@ export function unknownReferencesInProse( let m: RegExpExecArray | null; while ((m = re.exec(scanned)) !== null) { if (opts.documentStyle && m[2]) continue; // "shipped" is a value, not an identifier + // Backticks wrap anything, so a placeholder or a literal can arrive here. + if (m[1] !== undefined && !IDENTIFIER_SHAPE.test(m[1])) continue; const raw = (m[1] ?? m[2] ?? m[3] ?? '').toLowerCase(); if (raw.startsWith('$')) continue; // $lookup / $group are operators // Backticked SQL vocabulary is not a name claim; a call with parentheses is a function. diff --git a/packages/core/src/guard.ts b/packages/core/src/guard.ts index b434154..bca7432 100644 --- a/packages/core/src/guard.ts +++ b/packages/core/src/guard.ts @@ -7,6 +7,7 @@ import pkg from 'node-sql-parser'; import { AskSqlError } from './errors.js'; +import { clampMaxRows } from './row-cap.js'; import { hasMultipleStatements, maskCommentsAndStrings, stripCommentsAndStrings, trimTrailingNoise } from './strip.js'; import type { DialectInfo, EngineKind, GuardPolicy, GuardVerdict } from './types.js'; @@ -774,9 +775,6 @@ export interface GuardInput { readonly policy?: Partial; } -/** Nothing a caller asks for may exceed this; a row cap is a memory bound, not a preference. */ -const MAX_ROW_CAP = 100_000; - export function resolveGuardPolicy(partial?: Partial): GuardPolicy { const merged: { -readonly [K in keyof GuardPolicy]: GuardPolicy[K] } = { ...DEFAULT_GUARD_POLICY, @@ -786,11 +784,7 @@ export function resolveGuardPolicy(partial?: Partial): GuardPolicy }; // maxRows reaches here straight from an HTTP client, so it is clamped rather than trusted: // a NaN or a billion would otherwise become the row cap. - const requested = merged.maxRows; - merged.maxRows = - Number.isFinite(requested) && requested >= 1 - ? Math.min(Math.floor(requested), MAX_ROW_CAP) - : DEFAULT_GUARD_POLICY.maxRows; + merged.maxRows = clampMaxRows(merged.maxRows, DEFAULT_GUARD_POLICY.maxRows); if ((partial as { mode?: string } | undefined)?.mode && partial?.mode !== 'read-only') { throw new AskSqlError('CONFIG_ERROR', { detail: `GuardPolicy.mode '${String(partial?.mode)}' is not supported - the read-only floor is immovable in v1.`, diff --git a/packages/core/src/mongo/engine.ts b/packages/core/src/mongo/engine.ts index 2084573..c6264c8 100644 --- a/packages/core/src/mongo/engine.ts +++ b/packages/core/src/mongo/engine.ts @@ -37,9 +37,9 @@ import type { SchemaCatalog, } from '../types.js'; import { - DEFAULT_MONGO_GUARD_POLICY, guardPipeline, parsePipeline, + resolveMongoGuardPolicy, type MongoGuardPolicy, type MongoGuardVerdict, } from './guard.js'; @@ -204,7 +204,8 @@ function isNoOpPipeline(pipelineJson: string): boolean { } export function createMongoAskSql(config: MongoAskConfig): MongoAskEngine { - const policy: MongoGuardPolicy = { ...DEFAULT_MONGO_GUARD_POLICY, ...config.policy }; + // The prompt, the guard and the warning text all name one row cap. + const policy: MongoGuardPolicy = resolveMongoGuardPolicy(config.policy); let cached: { catalog: SchemaCatalog; at: number; ttl: number } | null = null; let inflight: Promise | null = null; diff --git a/packages/core/src/mongo/guard.ts b/packages/core/src/mongo/guard.ts index 21031f6..d8e07fb 100644 --- a/packages/core/src/mongo/guard.ts +++ b/packages/core/src/mongo/guard.ts @@ -5,6 +5,8 @@ * $limit injected or lowered to the row cap. Fail-closed. */ +import { clampMaxRows } from '../row-cap.js'; + export interface MongoGuardPolicy { readonly maxRows: number; readonly maxDepth: number; @@ -17,6 +19,12 @@ export const DEFAULT_MONGO_GUARD_POLICY: MongoGuardPolicy = Object.freeze({ maxRegexPatternLength: 200, }); +/** MongoDB rejects a non-integer or non-positive $limit. */ +export function resolveMongoGuardPolicy(partial?: Partial): MongoGuardPolicy { + const merged = { ...DEFAULT_MONGO_GUARD_POLICY, ...partial }; + return { ...merged, maxRows: clampMaxRows(merged.maxRows, DEFAULT_MONGO_GUARD_POLICY.maxRows) }; +} + export interface MongoGuardVerdict { readonly allowed: boolean; /** The re-serialized, capped pipeline as a bare JSON array string. Meaningful only when allowed. */ @@ -407,7 +415,11 @@ export function guardPipeline( if (walk.violation) return blocked(walk.violation.ruleId, walk.violation.reason); const capped = [...pipeline]; - const { autoLimited, loweredLimit } = capPipeline(capped, policy.maxRows); + // guardPipeline is public, so a direct caller's policy is clamped here too. + const { autoLimited, loweredLimit } = capPipeline( + capped, + clampMaxRows(policy.maxRows, DEFAULT_MONGO_GUARD_POLICY.maxRows), + ); return { allowed: true, diff --git a/packages/core/src/mongo/index.ts b/packages/core/src/mongo/index.ts index feeb9c9..d959e4d 100644 --- a/packages/core/src/mongo/index.ts +++ b/packages/core/src/mongo/index.ts @@ -8,6 +8,7 @@ export { DEFAULT_MONGO_GUARD_POLICY, guardPipeline, parsePipeline, + resolveMongoGuardPolicy, type MongoGuardPolicy, type MongoGuardVerdict, } from './guard.js'; diff --git a/packages/core/src/row-cap.ts b/packages/core/src/row-cap.ts new file mode 100644 index 0000000..0441d30 --- /dev/null +++ b/packages/core/src/row-cap.ts @@ -0,0 +1,14 @@ +/** + * The one row-cap clamp, shared by the SQL and MongoDB guards. maxRows arrives untrusted, from a + * user setting or an HTTP client. + */ + +/** Nothing a caller asks for may exceed this; a row cap is a memory bound, not a preference. */ +export const MAX_ROW_CAP = 100_000; + +/** Always a positive integer: `fallback` unless `requested` is finite and >= 1, then floored and capped. */ +export function clampMaxRows(requested: number | undefined, fallback: number): number { + return typeof requested === 'number' && Number.isFinite(requested) && requested >= 1 + ? Math.min(Math.floor(requested), MAX_ROW_CAP) + : fallback; +} diff --git a/packages/core/test/mongo-row-cap.test.ts b/packages/core/test/mongo-row-cap.test.ts new file mode 100644 index 0000000..1c2bdc3 --- /dev/null +++ b/packages/core/test/mongo-row-cap.test.ts @@ -0,0 +1,113 @@ +/** MongoDB rejects a $limit that is not a positive integer. */ +import { describe, expect, it, vi } from 'vitest'; +import { createMongoAskSql, guardPipeline, resolveMongoGuardPolicy, type MongoConnector } from '../src/mongo/index.js'; +import type { CustomModel, ExecuteOptions, ResultSet, SchemaCatalog } from '../src/types.js'; + +const CATALOG: SchemaCatalog = { + engine: 'mongodb', + schemas: ['shop'], + tables: [ + { + name: 'orders', + kind: 'table', + columns: [ + { name: '_id', dbType: 'objectId', nullable: false }, + { name: 'status', dbType: 'string', nullable: true }, + ], + primaryKey: ['_id'], + foreignKeys: [], + uniques: [], + checks: [], + indexes: [], + }, + ], + enums: [], + sequences: [], + triggers: [], + routines: [], + warnings: [], + fetchedAt: 'now', +}; + +const RESULT: ResultSet = { columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1, warnings: [] }; + +class FakeMongo implements MongoConnector { + readonly id = 'm'; + readonly name = 'Shop Mongo'; + readonly engine = 'mongodb' as const; + readonly database = 'shop'; + connect = vi.fn(async () => {}); + close = vi.fn(async () => {}); + async introspect(): Promise { + return CATALOG; + } + async aggregate(_c: string, _p: unknown[], _o?: ExecuteOptions): Promise { + return RESULT; + } +} + +const model = + (reply: string): CustomModel => + async () => + reply; + +/** `requested` as configured -> the only $limit MongoDB may legally be sent. */ +const CASES: { label: string; requested: number | undefined; expected: number }[] = [ + { label: 'fractional 12.5 floors to an integer', requested: 12.5, expected: 12 }, + { label: 'zero falls back to the default', requested: 0, expected: 1000 }, + { label: 'negative falls back to the default', requested: -5, expected: 1000 }, + { label: 'absurd 200000 is capped', requested: 200_000, expected: 100_000 }, + { label: 'missing value falls back to the default', requested: undefined, expected: 1000 }, + { label: 'NaN falls back to the default', requested: Number.NaN, expected: 1000 }, + { label: 'Infinity falls back to the default', requested: Number.POSITIVE_INFINITY, expected: 1000 }, +]; + +const lastLimit = (pipelineJson: string): unknown => { + const stages = JSON.parse(pipelineJson) as Record[]; + return stages[stages.length - 1]?.['$limit']; +}; + +describe('mongo row cap clamp', () => { + for (const { label, requested, expected } of CASES) { + it(`resolveMongoGuardPolicy: ${label}`, () => { + const policy = resolveMongoGuardPolicy(requested === undefined ? {} : { maxRows: requested }); + expect(policy.maxRows).toBe(expected); + expect(Number.isInteger(policy.maxRows)).toBe(true); + expect(policy.maxRows).toBeGreaterThan(0); + }); + + it(`guardPipeline injects the clamped $limit: ${label}`, () => { + const policy = resolveMongoGuardPolicy({ maxDepth: 400, maxRegexPatternLength: 200 }); + const v = guardPipeline('[{"$match":{}}]', { + ...policy, + ...(requested === undefined ? {} : { maxRows: requested }), + }); + expect(v.allowed).toBe(true); + const limit = lastLimit(v.pipelineJson); + expect(limit).toBe(expected); + expect(Number.isInteger(limit)).toBe(true); + expect(limit as number).toBeGreaterThan(0); + }); + + it(`engine ask injects the clamped $limit and names it in the warning: ${label}`, async () => { + const engine = createMongoAskSql({ + connector: new FakeMongo(), + model: model('```js\ndb.orders.aggregate([{"$match": {"status": "paid"}}])\n```\nPaid orders.'), + ...(requested === undefined ? {} : { policy: { maxRows: requested } }), + }); + const res = await engine.ask('paid orders'); + const limit = lastLimit(res.pipelineJson); + expect(limit).toBe(expected); + expect(Number.isInteger(limit)).toBe(true); + expect(limit as number).toBeGreaterThan(0); + expect(res.autoLimited).toBe(true); + expect(res.warnings.join(' ')).toContain(`A row limit of ${expected} was added automatically`); + }); + } + + it('an over-large trailing $limit is lowered to the clamped cap, not the raw setting', () => { + const v = guardPipeline('[{"$match":{}},{"$limit":9999999}]', { ...resolveMongoGuardPolicy({}), maxRows: 200_000 }); + expect(v.loweredLimit).toBe(true); + expect(lastLimit(v.pipelineJson)).toBe(100_000); + }); +}); diff --git a/packages/core/test/scope-grounding-edges.test.ts b/packages/core/test/scope-grounding-edges.test.ts index f7ff780..cbde13e 100644 --- a/packages/core/test/scope-grounding-edges.test.ts +++ b/packages/core/test/scope-grounding-edges.test.ts @@ -139,6 +139,32 @@ describe('the grounding floor is not disarmed by the English word "with"', () => }); }); +describe('backticks wrap more than identifiers', () => { + it('does not report a bound-parameter placeholder as a missing name', () => { + const answer = 'Bind the id as `?` and pass it yourself.'; + expect(unknownReferencesInProse(answer, CATALOG)).toEqual([]); + }); + + it('does not report a backticked literal or operator as a missing name', () => { + for (const answer of ['Use `2024-01-01` as the cutoff.', 'Compare with `>=` on the date.', 'Pass `:customer_id`.']) { + expect(unknownReferencesInProse(answer, CATALOG)).toEqual([]); + } + }); + + it('still reports a backticked name that really is missing', () => { + expect(unknownReferencesInProse('Add a `customer_history` table.', CATALOG)).toContain('customer_history'); + }); + + it('still accepts a backticked qualified name that exists', () => { + expect(unknownReferencesInProse('Read `shop.orders` for this.', CATALOG)).toEqual([]); + }); + + // Backticks are how MySQL quotes identifiers, and a hyphen is legal inside them. + it('still reports a missing hyphenated name', () => { + expect(unknownReferencesInProse('Check the `order-history` table.', CATALOG)).toContain('order-history'); + }); +}); + describe('SQL vocabulary in an answer is not an invented name', () => { // An answer that sets keywords in backticks - the normal way to write one - reported them as // invented names, costing a repair round-trip and marking it ungrounded. diff --git a/tests/bundle-size.test.ts b/tests/bundle-size.test.ts index aad30c7..7ac4d4e 100644 --- a/tests/bundle-size.test.ts +++ b/tests/bundle-size.test.ts @@ -28,7 +28,8 @@ const BUDGETS: Record = { // identifier quoting, 63->65 routing words disambiguated from identifiers ("the archive table", // "the best selling products", "the prompts table"). core: 65, - react: 20, + // 20 -> 23: copy controls, streamed-token progress, cell tooltips, export feedback, result-grid copy. + react: 23, // 12 -> 13: the CSRF/Host gate every adapter inherits, client-path confinement for // file engines, and the link-local check covering hex, octal and IPv4-mapped forms. server: 13, diff --git a/tests/peer-install-conflict.test.ts b/tests/peer-install-conflict.test.ts new file mode 100644 index 0000000..2678e38 --- /dev/null +++ b/tests/peer-install-conflict.test.ts @@ -0,0 +1,100 @@ +/** + * Proves the duplicate-core fix through a real npm install: a consumer pinned to an older core must + * get an ERESOLVE conflict, not a second core nested under the connector. Workspace links and + * `tools/packaged-consumer-test.mjs` both hoist one core for everyone, so neither can show this. + * Offline and file-based: the tarballs are packed here, nothing is fetched. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +/** sqlite: the smallest connector with no required native peer. The manifest test covers the rest. */ +const ADAPTER_DIR = join(ROOT, 'packages', 'sqlite'); + +const run = (cmd: string, args: string[], cwd: string) => + execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +let staging: string; +let adapterTarball: string; +let adapterManifest: Record>; +const consumers: string[] = []; + +/** A tarball for @asksql/core at `version`, so no registry is needed to pin an arbitrary version. */ +function packCore(version: string): string { + const dir = join(staging, `core-${version}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: '@asksql/core', version, type: 'module', main: 'index.js' }), + ); + writeFileSync(join(dir, 'index.js'), 'export const stub = true;\n'); + run('npm', ['pack', '--pack-destination', staging], dir); + return join(staging, `asksql-core-${version}.tgz`); +} + +function install(deps: Record): { dir: string; ok: boolean; output: string } { + const dir = mkdtempSync(join(tmpdir(), 'asksql-peer-consumer-')); + consumers.push(dir); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'peer-consumer', version: '1.0.0', private: true, dependencies: deps }), + ); + try { + return { dir, ok: true, output: run('npm', ['install', '--offline', '--no-audit', '--no-fund'], dir) }; + } catch (error) { + const e = error as { stdout?: string; stderr?: string; message?: string }; + return { dir, ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}${e.message ?? ''}` }; + } +} + +beforeAll(() => { + staging = mkdtempSync(join(tmpdir(), 'asksql-peer-tarballs-')); + // pnpm pack, not npm pack: only pnpm rewrites the `workspace:` protocol into the published range. + const packed = run('pnpm', ['pack', '--pack-destination', staging], ADAPTER_DIR); + adapterTarball = packed.trim().split('\n').filter(Boolean).pop() as string; + adapterManifest = JSON.parse(run('tar', ['-xzOf', adapterTarball, 'package/package.json'], staging)); +}, 120_000); + +afterAll(() => { + for (const dir of [staging, ...consumers]) rmSync(dir, { recursive: true, force: true }); +}); + +describe('a consumer pinned to another core version gets a conflict, not a second core', () => { + it('publishes the core peer as a plain semver range', () => { + expect(adapterManifest['peerDependencies']?.['@asksql/core']).toBe('>=0.6.0'); + expect(adapterManifest['dependencies']?.['@asksql/core']).toBeUndefined(); + }); + + it('fails with ERESOLVE against a core the peer range excludes', { timeout: 120_000 }, () => { + const core = packCore('0.5.0'); + const { dir, ok, output } = install({ + '@asksql/core': `file:${core}`, + '@asksql/sqlite': `file:${adapterTarball}`, + }); + expect(ok, `npm accepted the tree instead of reporting a conflict:\n${output}`).toBe(false); + expect(output).toContain('ERESOLVE'); + expect(output).toContain('peer @asksql/core@">=0.6.0" from @asksql/sqlite'); + expect( + existsSync(join(dir, 'node_modules', '@asksql', 'sqlite', 'node_modules', '@asksql', 'core')), + 'a second core was installed under the connector', + ).toBe(false); + }); + + it('installs a single shared core when the pin satisfies the range', { timeout: 120_000 }, () => { + const core = packCore('0.6.1'); + const { dir, ok, output } = install({ + '@asksql/core': `file:${core}`, + '@asksql/sqlite': `file:${adapterTarball}`, + }); + expect(ok, output).toBe(true); + const installed = JSON.parse( + readFileSync(join(dir, 'node_modules', '@asksql', 'core', 'package.json'), 'utf8'), + ) as { version: string }; + expect(installed.version).toBe('0.6.1'); + expect(existsSync(join(dir, 'node_modules', '@asksql', 'sqlite', 'node_modules'))).toBe(false); + }); +}); diff --git a/tests/peer-ranges.test.ts b/tests/peer-ranges.test.ts index cdfddb6..aff8bbd 100644 --- a/tests/peer-ranges.test.ts +++ b/tests/peer-ranges.test.ts @@ -5,18 +5,66 @@ * consequence; this catches the cause in `pnpm test`. */ import { describe, expect, it } from 'vitest'; -import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; const packagesDir = fileURLToPath(new URL('../packages', import.meta.url)); const manifests = readdirSync(packagesDir) - .map((dir) => join(packagesDir, dir, 'package.json')) - .filter((p) => existsSync(p)) - .map((p) => ({ path: p, json: JSON.parse(readFileSync(p, 'utf8')) as Record })) + .map((dir) => ({ dir: join(packagesDir, dir), path: join(packagesDir, dir, 'package.json') })) + .filter(({ path }) => existsSync(path)) + .map(({ dir, path }) => ({ + dir, + path, + json: JSON.parse(readFileSync(path, 'utf8')) as Record, + })) .filter(({ json }) => json['private'] !== true); +function sourceFiles(dir: string): string[] { + const out: string[] = []; + const walk = (d: string) => { + for (const entry of readdirSync(d)) { + const p = join(d, entry); + if (statSync(p).isDirectory()) walk(p); + else if (/\.tsx?$/.test(entry)) out.push(p); + } + }; + if (existsSync(dir)) walk(dir); + return out; +} + +/** True when the file emits an import of @asksql/core; `import type` and type-only names are erased. */ +function importsCoreAtRuntime(text: string): boolean { + if (/\bimport\s*\(\s*['"]@asksql\/core(?:\/[^'"]*)?['"]\s*\)/.test(text)) return true; + if (/(?:^|\n)\s*import\s+['"]@asksql\/core(?:\/[^'"]*)?['"]/.test(text)) return true; + for (const [, clause, spec] of text.matchAll( + /(?:^|\n)\s*(?:import|export)\s+([\s\S]*?)\s+from\s*['"]([^'"]+)['"]/g, + )) { + if (!/^@asksql\/core(\/|$)/.test(spec)) continue; + if (/^type\b/.test(clause.trim())) continue; + const braces = /\{([\s\S]*)\}/.exec(clause); + // A default or namespace binding is always emitted; otherwise every name must say `type`. + const bareBinding = clause + .replace(/\{[\s\S]*\}/, '') + .replace(/,/g, '') + .trim(); + if (bareBinding || !braces) return true; + const names = braces[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (names.some((n) => !/^type\s/.test(n))) return true; + } + return false; +} + +const coreRuntimeImporters = manifests.filter( + ({ dir, json }) => + json['name'] !== '@asksql/core' && + sourceFiles(join(dir, 'src')).some((f) => importsCoreAtRuntime(readFileSync(f, 'utf8'))), +); + describe('peer dependency ranges are publishable', () => { it('finds the workspace manifests', () => { expect(manifests.length).toBeGreaterThan(5); @@ -37,6 +85,43 @@ describe('peer dependency ranges are publishable', () => { } }); +/** + * A regular `dependencies` entry lets npm install a second copy of core under the package when the + * consumer pins a different version, and `instanceof AskSqlError` then returns false. Only a peer + * makes npm resolve one shared core. Derived from the sources, so a new connector is covered. + */ +describe('@asksql/core is a peer wherever it is imported at runtime', () => { + it('finds the packages that import core at runtime', () => { + expect(coreRuntimeImporters.map(({ json }) => json['name']).sort()).toContain('@asksql/sqlite'); + expect(coreRuntimeImporters.length).toBeGreaterThanOrEqual(8); + }); + + for (const { path, json } of coreRuntimeImporters) { + const name = String(json['name']); + const peers = (json['peerDependencies'] ?? {}) as Record; + const deps = (json['dependencies'] ?? {}) as Record; + const meta = (json['peerDependenciesMeta'] ?? {}) as Record; + + it(`${name} declares @asksql/core as a required peer`, () => { + expect( + peers['@asksql/core'], + `${path}: imports @asksql/core at runtime without declaring it as a peer`, + ).toBeTruthy(); + expect( + deps['@asksql/core'], + `${path}: core must be a peer only; a dependency installs a second copy`, + ).toBeUndefined(); + expect(meta['@asksql/core']?.optional, `${path}: the core peer must not be optional`).not.toBe(true); + // pnpm publishes `workspace:0.6.0` as the exact version, which the next core release puts + // straight back into conflict. + expect( + peers['@asksql/core'], + `${path}: the core peer must be a range, not an exact pin`, + ).toMatch(/^workspace:(>=|\^|~)/); + }); + } +}); + describe('changesets keeps the peer-dependent major rule switched off', () => { // Without this, changesets majors a package whenever a peer gets a non-patch bump, whatever // the range says - which is precisely how the accidental @asksql/server@1.0.0 arose. diff --git a/tools/nl-e2e.mjs b/tools/nl-e2e.mjs index 7f61347..7959c4b 100644 --- a/tools/nl-e2e.mjs +++ b/tools/nl-e2e.mjs @@ -8,6 +8,8 @@ * Needs Ollama and the Chinook data from tools/real-db-load.mjs. Each question has a known answer * computed from the data, so a fluent but wrong answer fails. Exit code 1 on a wrong answer, an * altered value, or any change to the stored data. + * + * One repair round is allowed and scored `recovered`, matching what the shipped surfaces offer. */ import { PostgresConnector } from '@asksql/postgres'; import { MysqlConnector } from '@asksql/mysql'; @@ -46,6 +48,9 @@ const cell = (v) => { /** A value that arrived as 1.0 where the database holds 1 has been altered on the way back. */ const looksLikeFloatedInteger = (s) => /^-?\d+\.0+$/.test(s); +/** The corrected query core attaches to a rejected one. Absent unless the database itself rejected it. */ +const suggestionAfter = (err) => (err?.code === 'DB_QUERY_ERROR' ? (err.suggestedSql ?? '') : ''); + const ENGINES = [ { key: 'postgres', @@ -138,9 +143,13 @@ const ENGINES = [ const model = await resolveModel({ provider: 'ollama', model: MODEL }); const rows = []; +let asked = 0; +let correct = 0; +let recovered = 0; let wrong = 0; let altered = 0; let mutated = 0; +let unreachable = 0; for (const engine of ENGINES) { let connector; @@ -152,12 +161,23 @@ for (const engine of ENGINES) { const before = await engine.count(); for (const { q, expect, forbidFloat } of QUESTIONS) { + asked++; let verdict; let detail = ''; try { const answer = await askSql.ask(q); - // The SQL engine hands back a runnable result; the Mongo engine hands back a pipeline. - const result = engine.mongo ? await askSql.execute(answer.pipelineJson, answer.collection) : await answer.run(); + let repairedFrom = ''; + let result; + try { + // The SQL engine hands back a runnable result; the Mongo engine hands back a pipeline. + result = engine.mongo ? await askSql.execute(answer.pipelineJson, answer.collection) : await answer.run(); + } catch (dbErr) { + const suggested = suggestionAfter(dbErr); + if (!suggested) throw dbErr; + // Guarded again on the way in, the same as the surfaces' re-approval path. + result = await askSql.execute(suggested); + repairedFrom = (dbErr.detail ?? dbErr.userMessage ?? dbErr.message ?? '').split('\n')[0].slice(0, 40); + } const cells = (result.rows ?? []).flat().map(cell); const missing = expect.filter((want) => !cells.some((c) => c === want || c.includes(want))); const floated = forbidFloat ? cells.filter(looksLikeFloatedInteger) : []; @@ -170,8 +190,13 @@ for (const engine of ENGINES) { verdict = 'WRONG'; wrong++; detail = `expected ${missing.join(', ')}; got ${cells.slice(0, 4).join(' | ').slice(0, 60)}`; + } else if (repairedFrom) { + verdict = 'recovered'; + recovered++; + detail = `${result.rows.length} rows after repair of: ${repairedFrom}`; } else { verdict = 'ok'; + correct++; detail = `${result.rows.length} rows, ${answer.repairs} repairs`; } } catch (err) { @@ -196,7 +221,7 @@ for (const engine of ENGINES) { 'ERROR', (err.userMessage ?? err.message ?? String(err)).split('\n')[0].slice(0, 70), ]); - wrong++; + unreachable++; } finally { await connector?.close?.().catch(() => {}); } @@ -207,10 +232,10 @@ console.log('| Engine | Question | Result | Detail |'); console.log('|---|---|---|---|'); for (const [a, b, c, d] of rows) console.log(`| ${a} | ${b} | ${c} | ${d} |`); -const asked = rows.filter(([, b]) => b !== 'stored data unchanged' && b !== 'connect').length; -const ok = rows.filter(([, , v]) => v === 'ok').length; -console.log(`\n${asked} questions asked, ${ok - (rows.length - asked)} answered correctly.`); +console.log( + `\n${asked} questions asked: ${correct} correct first try, ${recovered} recovered after one repair, ${wrong} wrong or failed.`, +); if (altered) console.log(`${altered} ALTERED VALUE(S)`); if (mutated) console.log(`${mutated} ENGINE(S) HAD DATA CHANGE`); -if (wrong) console.log(`${wrong} wrong or failed answer(s)`); -process.exit(altered === 0 && mutated === 0 && wrong === 0 ? 0 : 1); +if (unreachable) console.log(`${unreachable} ENGINE(S) UNREACHABLE`); +process.exit(altered === 0 && mutated === 0 && wrong === 0 && unreachable === 0 ? 0 : 1); From 9ede69813e0defd958c1178c80bc78dae7c2a2a4 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Sun, 9 Aug 2026 02:09:40 +0800 Subject: [PATCH 3/4] Format the peer-range test --- tests/peer-ranges.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/peer-ranges.test.ts b/tests/peer-ranges.test.ts index aff8bbd..ddb4507 100644 --- a/tests/peer-ranges.test.ts +++ b/tests/peer-ranges.test.ts @@ -114,10 +114,9 @@ describe('@asksql/core is a peer wherever it is imported at runtime', () => { expect(meta['@asksql/core']?.optional, `${path}: the core peer must not be optional`).not.toBe(true); // pnpm publishes `workspace:0.6.0` as the exact version, which the next core release puts // straight back into conflict. - expect( - peers['@asksql/core'], - `${path}: the core peer must be a range, not an exact pin`, - ).toMatch(/^workspace:(>=|\^|~)/); + expect(peers['@asksql/core'], `${path}: the core peer must be a range, not an exact pin`).toMatch( + /^workspace:(>=|\^|~)/, + ); }); } }); From 0aa3ea1a81e95ea515766f931b365c5b33dd59a5 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Sun, 9 Aug 2026 02:16:17 +0800 Subject: [PATCH 4/4] Make @asksql/core a peer of every package that imports it A regular dependency let npm install a second copy of core under the adapter whenever the consumer pinned a different version, and instanceof AskSqlError then returned false for them. Only a peer makes npm resolve one shared core. The range is >= rather than ^: a caret on a 0.x package excludes the next minor, and with onlyUpdatePeerDependentsWhenOutOfRange a core minor would then major-bump all nine. yarn and legacy-peer-deps do not auto-install peers, so installing an adapter on its own now fails there. Each README names core in the install line and says so. React gains copy controls on prose and the result grid, streamed output while a local model works, cell tooltips, export feedback, and a fix for the red error shown while a schema answer was still being written. --- packages/duckdb/README.md | 10 +- packages/duckdb/package.json | 5 +- packages/mcp/README.md | 3 + packages/mcp/package.json | 5 +- packages/mongodb/README.md | 3 + packages/mongodb/package.json | 5 +- packages/mysql/README.md | 3 + packages/mysql/package.json | 5 +- packages/oracle/README.md | 3 + packages/oracle/package.json | 5 +- packages/postgres/README.md | 3 + packages/postgres/package.json | 5 +- packages/react/README.md | 3 + packages/react/package.json | 5 +- packages/react/src/components.tsx | 166 ++++++++++---- packages/react/src/styles.ts | 12 +- packages/react/src/useAskSql.ts | 131 +++++++---- packages/react/test/components.test.tsx | 293 +++++++++++++++++++++++- packages/react/test/max-rows.test.tsx | 15 ++ packages/react/test/useAskSql.test.tsx | 150 +++++++++++- packages/server/README.md | 3 + packages/server/package.json | 3 +- packages/sqlite/README.md | 3 + packages/sqlite/package.json | 7 +- pnpm-lock.yaml | 46 ++-- tools/packaged-consumer-test.mjs | 5 +- 26 files changed, 755 insertions(+), 142 deletions(-) diff --git a/packages/duckdb/README.md b/packages/duckdb/README.md index 1fd87fd..c6de2c2 100644 --- a/packages/duckdb/README.md +++ b/packages/duckdb/README.md @@ -1,11 +1,10 @@ # @asksql/duckdb The DuckDB connector for [AskSQL](https://github.com/rahulmahadik/AskSQL): local analytics over -CSV / JSON / NDJSON / Parquet / Excel files or a DuckDB database file, with no backend. Two -entry points share one implementation: +CSV / JSON / NDJSON / Parquet / Excel files, a portable `.sql` dump (CREATE TABLE + INSERT), or a +DuckDB database file, with no backend. Two entry points share one implementation: -- `@asksql/duckdb` (Node), on `@duckdb/node-api`. Also loads a portable `.sql` dump - (CREATE TABLE + INSERT). +- `@asksql/duckdb` (Node), on `@duckdb/node-api`. - `@asksql/duckdb/browser`, on `@duckdb/duckdb-wasm`, in a Web Worker with optional OPFS persistence. Data never leaves the tab. @@ -16,6 +15,9 @@ npm i @asksql/core @asksql/duckdb @duckdb/node-api # Node npm i @asksql/core @asksql/duckdb @duckdb/duckdb-wasm # browser ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + ## Node ```ts diff --git a/packages/duckdb/package.json b/packages/duckdb/package.json index 404aab1..a96ad51 100644 --- a/packages/duckdb/package.json +++ b/packages/duckdb/package.json @@ -22,10 +22,8 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@duckdb/duckdb-wasm": ">=1.28", "@duckdb/node-api": ">=1.4.0-r.1" }, @@ -38,6 +36,7 @@ } }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@duckdb/duckdb-wasm": "^1.32.0", "@duckdb/node-api": "1.5.4-r.1" }, diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b95309a..66421d6 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -23,6 +23,9 @@ MongoDB uses the separate `createMongoAskSql` engine and is not exposed over MCP npm i @asksql/core @asksql/mcp @modelcontextprotocol/sdk ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + ## Setting it up in an MCP host An MCP host launches your server as a subprocess and talks to it over stdin/stdout, so you diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e3eb608..2223e18 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -18,10 +18,8 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@modelcontextprotocol/sdk": ">=1" }, "peerDependenciesMeta": { @@ -30,6 +28,7 @@ } }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "license": "Apache-2.0", diff --git a/packages/mongodb/README.md b/packages/mongodb/README.md index acc1b6a..94ae0c8 100644 --- a/packages/mongodb/README.md +++ b/packages/mongodb/README.md @@ -10,6 +10,9 @@ install it yourself. npm i @asksql/core @asksql/mongodb mongodb ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + Requires Node 20+ and mongodb 6.0 or newer. ```ts diff --git a/packages/mongodb/package.json b/packages/mongodb/package.json index ae8631f..4e2a108 100644 --- a/packages/mongodb/package.json +++ b/packages/mongodb/package.json @@ -18,13 +18,12 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "mongodb": ">=6.0" }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "mongodb": "^6.10.0" }, "license": "Apache-2.0", diff --git a/packages/mysql/README.md b/packages/mysql/README.md index c4a93e3..adadf78 100644 --- a/packages/mysql/README.md +++ b/packages/mysql/README.md @@ -9,6 +9,9 @@ the same driver here; only the JetBrains plugin ships the MariaDB JDBC client in npm i @asksql/core @asksql/mysql mysql2 ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + Requires Node 20+ and mysql2 3.6 or newer. ```ts diff --git a/packages/mysql/package.json b/packages/mysql/package.json index 1e1dc6e..a6903a1 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -18,13 +18,12 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "mysql2": ">=3.6" }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "mysql2": "^3.22.6" }, "license": "Apache-2.0", diff --git a/packages/oracle/README.md b/packages/oracle/README.md index 7aa238e..f85e89f 100644 --- a/packages/oracle/README.md +++ b/packages/oracle/README.md @@ -9,6 +9,9 @@ pure-JS Thin mode: no Oracle Instant Client, no native libraries. npm i @asksql/core @asksql/oracle oracledb ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + Requires Node 20+ and oracledb 6.0 or newer. ```ts diff --git a/packages/oracle/package.json b/packages/oracle/package.json index 9c5f8a8..107a4a6 100644 --- a/packages/oracle/package.json +++ b/packages/oracle/package.json @@ -18,13 +18,12 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "oracledb": ">=6.0" }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "oracledb": "^6.5.0" }, "license": "Apache-2.0", diff --git a/packages/postgres/README.md b/packages/postgres/README.md index dc447f6..c6e217a 100644 --- a/packages/postgres/README.md +++ b/packages/postgres/README.md @@ -8,6 +8,9 @@ peer dependency, so you install it yourself. npm i @asksql/core @asksql/postgres pg ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + ```ts import { PostgresConnector } from '@asksql/postgres'; diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 971bb79..e9f9e1e 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -18,13 +18,12 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "pg": ">=8.11" }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@types/pg": "^8.20.0", "pg": "^8.22.0" }, diff --git a/packages/react/README.md b/packages/react/README.md index b32150f..6662218 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -23,6 +23,9 @@ Turn on `answerSchemaQuestions` and questions that aren't a data query - "how ar npm i @asksql/core @asksql/react ``` +`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it +for you, so name it explicitly as above. + ## Drop-in chat ```tsx diff --git a/packages/react/package.json b/packages/react/package.json index 635278c..b9d7e99 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -18,14 +18,13 @@ "scripts": { "build": "tsc -b" }, - "dependencies": { - "@asksql/core": "workspace:^" - }, "peerDependencies": { + "@asksql/core": "workspace:>=0.6.0", "react": ">=18", "react-dom": ">=18" }, "devDependencies": { + "@asksql/core": "workspace:>=0.6.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "react": "^19.2.7", diff --git a/packages/react/src/components.tsx b/packages/react/src/components.tsx index 194b0cc..565d6f1 100644 --- a/packages/react/src/components.tsx +++ b/packages/react/src/components.tsx @@ -30,7 +30,19 @@ function inlineMarkdown(line: string): JSX.Element[] { } /** Render explanation markdown: drop a redundant leading "Explanation:", bullets for "- "/"* " lines, ```fenced``` blocks as code. */ -function Markdown({ text, className }: { text: string; className?: string }): JSX.Element { +/** The sentence the engine appends to a proposed write. */ +const READ_ONLY_LINE_MARKER = 'AskSQL is read-only'; + +function Markdown({ + text, + className, + renderCode, +}: { + text: string; + className?: string; + /** Renders a fenced block; returning null drops the fence. Defaults to a plain code
. */
+  renderCode?: (code: string) => JSX.Element | null;
+}): JSX.Element {
   const body = text.replace(/^\s*(\*\*|__)?\s*Explanation\s*(\*\*|__)?\s*:\s*/iu, '');
   const lines = body.split('\n');
   const blocks: JSX.Element[] = [];
@@ -43,14 +55,19 @@ function Markdown({ text, className }: { text: string; className?: string }): JS
       i++;
       while (i < lines.length && !/^\s*```/u.test(lines[i]!)) code.push(lines[i++]!);
       i++; // skip the closing fence
-      blocks.push(
-        
-          {code.join('\n')}
-        
, - ); + const fence = code.join('\n'); + const rendered = renderCode ? renderCode(fence) :
{fence}
; + // Wrapped so the key stays on this block whatever the renderer returns. + if (rendered !== null) blocks.push(
{rendered}
); + key++; continue; } const line = lines[i++]!; + // An empty
collapses, so a blank line carries its own gap. + if (line.trim() === '') { + blocks.push(
); + continue; + } const bullet = /^\s*[-*]\s+/u.test(line); blocks.push(
@@ -61,6 +78,40 @@ function Markdown({ text, className }: { text: string; className?: string }): JS return
{blocks}
; } +/** Copies to the clipboard, acking only once the write resolved. A function `text` is built on click. */ +function CopyButton({ text, className }: { text: string | (() => string); className?: string }): JSX.Element { + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle'); + const settle = (next: 'copied' | 'failed') => { + setState(next); + setTimeout(() => setState('idle'), 1200); + }; + return ( + + ); +} + export interface AskSqlChatProps { readonly transport: Transport; readonly connectionId?: string; @@ -128,8 +179,16 @@ export function AskSqlChat(props: AskSqlChatProps): JSX.Element { const activeCaps = connections.find((c) => c.id === (activeConn ?? props.connectionId))?.capabilities; const canPlan = activeCaps?.supportsExplain ?? true; + // A turn is patched many times per question; only a new turn always follows. + const lastTurnId = useRef(undefined); useEffect(() => { - threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight }); + const el = threadRef.current; + if (!el) return; + const newest = turns[turns.length - 1]?.id; + const isNewTurn = newest !== lastTurnId.current; + lastTurnId.current = newest; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + if (isNewTurn || nearBottom) el.scrollTo({ top: el.scrollHeight }); }, [turns]); // A turn's SQL was written for the schema of the connection that produced it, and Run/Explain @@ -229,7 +288,7 @@ export function AskSqlChat(props: AskSqlChatProps): JSX.Element { /> {busy ? ( ) : (
)} - {turn.phase === 'stopped' &&
Stopped.
} + {turn.phase === 'stopped' &&
Cancelled.
}
); @@ -453,36 +532,33 @@ function stageLabel(stage?: string): string { return 'Reading schema...'; case 'prune': return 'Finding relevant tables...'; + case 'prompt': + return 'Building the prompt...'; case 'llm': return 'Writing SQL...'; + case 'extract': + return 'Reading the reply...'; case 'repair': - return 'Refining SQL...'; + return 'Correcting the SQL...'; case 'guard': return 'Checking safety...'; + case 'execute': + return 'Running the query...'; + case 'schema_answer': + return 'Answering from your schema...'; case 'done': - return 'Ready'; + return 'Done'; default: return 'Thinking...'; } } export function SqlBlock({ sql }: { sql: string }): JSX.Element { - const [copied, setCopied] = useState(false); return (
SQL - +
         {sql}
@@ -494,16 +570,24 @@ export function SqlBlock({ sql }: { sql: string }): JSX.Element {
 export function ResultTable({ result }: { result: ResultSet }): JSX.Element {
   const chartable = useMemo(() => isChartable(result), [result]);
   const [view, setView] = useState<'table' | 'chart'>('table');
+  const [exportState, setExportState] = useState<'idle' | 'done' | 'failed'>('idle');
   const download = () => {
-    // Built on click only: rendering the whole result set to CSV up front charges every
-    // answer for an export most users never ask for.
-    const blob = new Blob([toCsv(result.columns, result.rows)], { type: 'text/csv' });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = 'asksql-results.csv';
-    a.click();
-    URL.revokeObjectURL(url);
+    try {
+      // Built on click only: rendering the whole result set to CSV up front charges every
+      // answer for an export most users never ask for.
+      const blob = new Blob([toCsv(result.columns, result.rows)], { type: 'text/csv' });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = 'asksql-results.csv';
+      a.click();
+      // Revoked on a later tick: the browser has not read the blob yet when the click returns.
+      setTimeout(() => URL.revokeObjectURL(url), 30_000);
+      setExportState('done');
+    } catch {
+      setExportState('failed');
+    }
+    setTimeout(() => setExportState('idle'), 1600);
   };
 
   if (result.rowCount === 0) {
@@ -533,7 +617,8 @@ export function ResultTable({ result }: { result: ResultSet }): JSX.Element {
                   {row.map((cell, ci) => {
                     const d = formatCell(cell, result.columns[ci]);
                     return (
-                      
+                      // A cell wider than the column is clipped, so the full value lives in the tooltip.
+                      
                         {d.text}
                       
                     );
@@ -559,8 +644,9 @@ export function ResultTable({ result }: { result: ResultSet }): JSX.Element {
             {view === 'table' ? 'Chart' : 'Table'}
           
         )}
+         toCsv(result.columns, result.rows)} />
         
         {result.warnings.map((w, i) => (
           
diff --git a/packages/react/src/styles.ts b/packages/react/src/styles.ts
index 395dd8e..6041cef 100644
--- a/packages/react/src/styles.ts
+++ b/packages/react/src/styles.ts
@@ -35,10 +35,16 @@ export const ASKSQL_CSS = `
 .asksql-turn { display: flex; flex-direction: column; gap: 8px; }
 .asksql-role { align-self: flex-end; font-size: 11px; font-weight: 600; color: var(--aq-muted); }
 .asksql-role-assistant { align-self: flex-start; }
+/* The question is shown verbatim: its own line breaks stay. */
 .asksql-q { align-self: flex-end; background: var(--aq-accent); color: var(--aq-accent-fg);
-  padding: 8px 12px; border-radius: 12px 12px 2px 12px; max-width: 85%; }
+  padding: 8px 12px; border-radius: 12px 12px 2px 12px; max-width: 85%;
+  white-space: pre-wrap; overflow-wrap: anywhere; }
 .asksql-a { align-self: flex-start; max-width: 100%; width: 100%; }
 .asksql-stage { color: var(--aq-muted); font-size: 12px; display: flex; align-items: center; gap: 6px; }
+/* Raw model output while a stage runs, capped so it never pushes the answer off screen. */
+.asksql-stream { margin: 4px 0; color: var(--aq-muted); opacity: .8;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.4;
+  white-space: pre-wrap; overflow-wrap: anywhere; max-height: 120px; overflow: hidden; }
 .asksql-spinner { width: 12px; height: 12px; border: 2px solid var(--aq-border);
   border-top-color: var(--aq-accent); border-radius: 50%; animation: aq-spin.7s linear infinite; }
 @keyframes aq-spin { to { transform: rotate(360deg); } }
@@ -59,6 +65,10 @@ export const ASKSQL_CSS = `
 .asksql-explain code { font-family: ui-monospace, monospace; font-size: 0.92em; background: var(--aq-code-bg, rgba(127,127,127,0.12)); border-radius: 3px; padding: 0 3px; }
 .asksql-md-bullet { padding-left: 1em; text-indent: -0.75em; }
 .asksql-md-bullet::before { content: "\\2022  "; }
+/* An empty line collapses, so the blank carries the paragraph gap. */
+.asksql-md-blank { height: 0.7em; }
+.asksql-prose { display: flex; flex-direction: column; }
+.asksql-prose-copy { align-self: flex-end; margin-top: 4px; }
 .asksql-warn { color: var(--aq-warn); font-size: 12px; }
 .asksql-note { color: var(--aq-muted); font-size: 12px; margin-top: 4px; opacity: 0.85; }
 .asksql-error { color: var(--aq-danger); font-size: 13px; padding: 8px 12px;
diff --git a/packages/react/src/useAskSql.ts b/packages/react/src/useAskSql.ts
index 12eca64..c6bd6a5 100644
--- a/packages/react/src/useAskSql.ts
+++ b/packages/react/src/useAskSql.ts
@@ -36,6 +36,8 @@ export interface Turn {
   /** EXPLAIN-plan text, populated on demand. */
   plan?: string;
   planning?: boolean;
+  /** Raw model output for the stage in flight; unparsed, and dropped once the stage settles. */
+  streamText?: string;
   error?: { code: string; userMessage: string; retryable: boolean };
   /** A corrected query the server suggested after a failed run (apply to retry). */
   suggestedSql?: string;
@@ -76,6 +78,10 @@ const CONTEXT_TURNS = 4;
 /** Older turns keep their text but drop their rows: a long session otherwise retains every result set. */
 const MAX_TURNS_WITH_ROWS = 20;
 const MAX_TURNS = 200;
+/** Streamed tokens land far faster than the UI needs. */
+const STREAM_FLUSH_MS = 80;
+/** Only the tail of the model's output is shown as progress. */
+const MAX_STREAM_CHARS = 400;
 
 function trimTranscript(turns: Turn[]): Turn[] {
   const kept = turns.length > MAX_TURNS ? turns.slice(-MAX_TURNS) : turns;
@@ -176,7 +182,7 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
         if (abortRef.current === controller) abortRef.current = null;
       }
     },
-    [opts.transport, opts.connectionId, patch],
+    [opts.transport, opts.connectionId, opts.maxRows, patch],
   );
 
   const ask = useCallback(
@@ -198,8 +204,19 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
       let generatedSql: string | undefined;
       let generatedCollection: string | undefined;
       let askErrorCode: string | undefined;
+      let askError: Turn['error'] | undefined;
       // Whether the stream left the turn in a state the UI can render.
       let settled = false;
+      let flushTimer: ReturnType | null = null;
+      let flushed = '';
+      // Per ask: tokens arrive between renders and `patch` cannot append.
+      let stream = '';
+      const stopFlush = () => {
+        if (flushTimer !== null) {
+          clearInterval(flushTimer);
+          flushTimer = null;
+        }
+      };
       try {
         for await (const ev of opts.transport.chat({
           question: q,
@@ -221,24 +238,24 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
         const e = err as { name?: string; code?: string; userMessage?: string; retryable?: boolean };
         askErrorCode = e.code;
         settled = true;
-        // A user Stop aborts the stream; surface a neutral stopped state, not a red error.
+        stopFlush();
+        // A user Cancel aborts the stream; surface a neutral stopped state, not a red error.
         if (e.name === 'AbortError' || controller.signal.aborted) {
-          patch(id, { phase: 'stopped', error: undefined });
+          patch(id, { phase: 'stopped', error: undefined, streamText: undefined });
         } else {
-          patch(id, {
-            phase: 'error',
-            error: {
-              code: e.code ?? 'LLM_UNAVAILABLE',
-              userMessage: e.userMessage ?? 'Something went wrong.',
-              retryable: e.retryable ?? false,
-            },
-          });
+          askError = {
+            code: e.code ?? 'LLM_UNAVAILABLE',
+            userMessage: e.userMessage ?? 'Something went wrong.',
+            retryable: e.retryable ?? false,
+          };
+          patch(id, { phase: 'error', error: askError, streamText: undefined });
         }
       }
 
       // A stream that ends with neither SQL nor an error would leave the turn spinning forever.
       if (!settled) {
-        if (controller.signal.aborted) patch(id, { phase: 'stopped', error: undefined });
+        stopFlush();
+        if (controller.signal.aborted) patch(id, { phase: 'stopped', error: undefined, streamText: undefined });
         else
           patch(id, {
             phase: 'error',
@@ -247,6 +264,7 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
               userMessage: 'The response ended before an answer arrived.',
               retryable: true,
             },
+            streamText: undefined,
           });
       }
 
@@ -256,28 +274,35 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
         opts.answerSchemaQuestions &&
         (askErrorCode === 'LLM_BAD_OUTPUT' || askErrorCode === 'LLM_REFUSAL')
       ) {
-        try {
-          const sa = await opts.transport.explainSchema(q, opts.connectionId, context, controller.signal);
-          patch(id, {
-            phase: 'done',
-            error: undefined,
-            // Recorded as this turn's sql too, so a follow-up like "run that" has it as context.
-            ...(sa.proposedSql ? { sql: sa.proposedSql } : {}),
-            schemaAnswer: {
-              answer: sa.answer,
-              grounded: sa.grounded,
-              unknownReferences: [...sa.unknownReferences],
-              isSchemaChange: sa.isSchemaChange,
-              ...(sa.proposedSql ? { proposedSql: sa.proposedSql } : {}),
-            },
-          });
-        } catch {
-          /* keep the original error */
+        // The fallback is a second round-trip, so the turn goes back to thinking.
+        if (!controller.signal.aborted) {
+          patch(id, { phase: 'thinking', stage: 'schema_answer', error: undefined });
+          try {
+            const sa = await opts.transport.explainSchema(q, opts.connectionId, context, controller.signal);
+            patch(id, {
+              phase: 'done',
+              error: undefined,
+              // Recorded as this turn's sql too, so a follow-up like "run that" has it as context.
+              ...(sa.proposedSql ? { sql: sa.proposedSql } : {}),
+              schemaAnswer: {
+                answer: sa.answer,
+                grounded: sa.grounded,
+                unknownReferences: [...sa.unknownReferences],
+                isSchemaChange: sa.isSchemaChange,
+                ...(sa.proposedSql ? { proposedSql: sa.proposedSql } : {}),
+              },
+            });
+          } catch {
+            // A cancel during the fallback is an abort, not a failure.
+            if (controller.signal.aborted) patch(id, { phase: 'stopped', error: undefined });
+            else patch(id, { phase: 'error', error: askError });
+          }
         }
       }
-      // Cleared after the fallback, so Stop still reaches it; only the owner clears it, because a
-      // Stop plus a new question can leave this turn unwinding behind the next one.
+      // Cleared after the fallback, so Cancel still reaches it; only the owner clears it, because a
+      // Cancel plus a new question can leave this turn unwinding behind the next one.
       if (abortRef.current === controller) abortRef.current = null;
+      stopFlush();
       // Stay busy across the auto-run so Stop stays available; skip a query the user just cancelled.
       if (generatedSql && !opts.requireApproval && !controller.signal.aborted) {
         await doRun(id, generatedSql, generatedCollection);
@@ -288,24 +313,46 @@ export function useAskSql(opts: UseAskSqlOptions): UseAskSqlResult {
       }
 
       function applyEvent(turnId: string, ev: ChatEvent) {
-        if (ev.type === 'stage') patch(turnId, { stage: ev.stage });
-        else if (ev.type === 'sql')
+        if (ev.type === 'stage') {
+          // The engine re-emits llm/repair per attempt, so a stage starts the tail over.
+          stream = '';
+          patch(turnId, { stage: ev.stage });
+        } else if (ev.type === 'token') {
+          stream = (stream + (ev.text ?? '')).slice(-MAX_STREAM_CHARS);
+          if (flushTimer === null) {
+            flushTimer = setInterval(() => {
+              // A cancelled ask keeps ticking until its transport unwinds.
+              if (abortRef.current !== controller || controller.signal.aborted) {
+                stopFlush();
+                return;
+              }
+              const text = stream;
+              if (text === flushed) return;
+              flushed = text;
+              setTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, streamText: text } : t)));
+            }, STREAM_FLUSH_MS);
+          }
+        } else if (ev.type === 'sql') {
+          stopFlush();
+          stream = '';
           patch(turnId, {
             phase: 'sql_ready',
             sql: ev.sql,
             explanation: ev.explanation,
             autoLimited: ev.autoLimited,
+            streamText: undefined,
             ...(ev.collection ? { collection: ev.collection } : {}),
           });
-        else if (ev.type === 'error')
-          patch(turnId, {
-            phase: 'error',
-            error: {
-              code: ev.code ?? 'LLM_UNAVAILABLE',
-              userMessage: ev.userMessage ?? 'Something went wrong.',
-              retryable: ev.retryable ?? false,
-            },
-          });
+        } else if (ev.type === 'error') {
+          stopFlush();
+          stream = '';
+          askError = {
+            code: ev.code ?? 'LLM_UNAVAILABLE',
+            userMessage: ev.userMessage ?? 'Something went wrong.',
+            retryable: ev.retryable ?? false,
+          };
+          patch(turnId, { phase: 'error', error: askError, streamText: undefined });
+        }
       }
     },
     [turns, opts.transport, opts.connectionId, opts.requireApproval, opts.answerSchemaQuestions, patch, doRun],
diff --git a/packages/react/test/components.test.tsx b/packages/react/test/components.test.tsx
index 6ef5497..c363806 100644
--- a/packages/react/test/components.test.tsx
+++ b/packages/react/test/components.test.tsx
@@ -10,8 +10,14 @@ import { createRoot } from 'react-dom/client';
 import userEvent from '@testing-library/user-event';
 import { AskSqlBubble, AskSqlChat, ResultTable, SqlBlock } from '../src/components.js';
 import type { ResultSet } from '@asksql/core';
+import type { AskParams } from '../src/client.js';
 import { chatOf, deferred, makeTransport, resultOf } from './helpers.js';
 
+/** jsdom reports every scroll metric as 0. */
+function stubMetric(el: Element, prop: string, value: number) {
+  Object.defineProperty(el, prop, { value, configurable: true });
+}
+
 // Fill jsdom gaps the components rely on: object-URLs (CSV export), element
 // scrolling (thread auto-scroll), and a writable clipboard (Copy).
 beforeAll(() => {
@@ -107,6 +113,42 @@ describe('AskSqlChat', () => {
     expect(explain.textContent).not.toContain('```');
   });
 
+  it('keeps blank lines between paragraphs visible', async () => {
+    const user = userEvent.setup();
+    const transport = makeTransport({
+      chat: chatOf(
+        { type: 'sql', sql: 'SELECT 1', explanation: 'First paragraph.\n\nSecond paragraph.' },
+        { type: 'done' },
+      ),
+    });
+    const { container } = render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    await waitFor(() => expect(container.querySelector('.asksql-explain')).toBeTruthy());
+    const explain = container.querySelector('.asksql-explain')!;
+    expect(explain.querySelectorAll('.asksql-md-blank')).toHaveLength(1);
+    expect(screen.getByText('First paragraph.')).toBeTruthy();
+    expect(screen.getByText('Second paragraph.')).toBeTruthy();
+  });
+
+  it('copies the raw markdown source of an explanation, not the rendered text', async () => {
+    const user = userEvent.setup();
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+    const explanation = 'Adds a column:\n```sql\nALTER TABLE t ADD COLUMN x int;\n```\n- one\n- two';
+    const transport = makeTransport({
+      chat: chatOf({ type: 'sql', sql: 'SELECT 1', explanation }, { type: 'done' }),
+    });
+    const { container } = render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    await waitFor(() => expect(container.querySelector('.asksql-prose-copy')).toBeTruthy());
+    await user.click(container.querySelector('.asksql-prose-copy') as HTMLButtonElement);
+    expect(writeText).toHaveBeenCalledWith(explanation);
+  });
+
   it('approval mode gates results behind a Run query button', async () => {
     const user = userEvent.setup();
     const execute = vi.fn(async () => resultOf());
@@ -454,6 +496,116 @@ describe('AskSqlChat', () => {
     expect(await screen.findByText(/row limit was applied automatically/i)).toBeTruthy();
   });
 
+  it('leaves the view where the reader put it when a turn updates, but follows a new turn', async () => {
+    const user = userEvent.setup();
+    const gate = deferred();
+    const transport = makeTransport({
+      chat: async function* () {
+        yield { type: 'stage', stage: 'llm' } as const;
+        await gate.promise;
+        yield { type: 'sql', sql: 'SELECT 1' } as const;
+        yield { type: 'done' } as const;
+      },
+      execute: async () => resultOf(),
+    });
+    const { container } = render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    const thread = container.querySelector('.asksql-thread') as HTMLElement;
+    const scrollTo = Element.prototype.scrollTo as unknown as ReturnType;
+    await screen.findByText(/Writing SQL/);
+    expect(scrollTo).toHaveBeenCalled();
+
+    // The reader has scrolled back up.
+    stubMetric(thread, 'scrollHeight', 1000);
+    stubMetric(thread, 'clientHeight', 200);
+    stubMetric(thread, 'scrollTop', 0);
+    scrollTo.mockClear();
+
+    gate.resolve();
+    await waitFor(() => expect(screen.getByText('SELECT 1')).toBeTruthy());
+    await waitFor(() => expect(screen.getByText(/2 rows/)).toBeTruthy());
+    expect(scrollTo).not.toHaveBeenCalled();
+
+    // Still scrolled back up.
+    scrollTo.mockClear();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q2');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+    await waitFor(() => expect(screen.getByText('q2')).toBeTruthy());
+    expect(scrollTo).toHaveBeenCalled();
+  });
+
+  it('shows the streamed model text as plain progress text', async () => {
+    const user = userEvent.setup();
+    const gate = deferred();
+    const transport = makeTransport({
+      chat: async function* () {
+        yield { type: 'stage', stage: 'llm' } as const;
+        yield { type: 'token', text: 'maybe ```sql' } as const;
+        await gate.promise;
+        yield { type: 'sql', sql: 'SELECT 1' } as const;
+        yield { type: 'done' } as const;
+      },
+    });
+    const { container } = render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    await waitFor(() => expect(container.querySelector('.asksql-stream')).toBeTruthy());
+    const stream = container.querySelector('.asksql-stream') as HTMLElement;
+    expect(stream.textContent).toBe('maybe ```sql');
+    expect(getComputedStyle(stream).whiteSpace).toBe('pre-wrap');
+    expect(getComputedStyle(stream).maxHeight).toBe('120px');
+
+    gate.resolve();
+    await waitFor(() => expect(container.querySelector('.asksql-stream')).toBeNull());
+  });
+
+  it('names the prompt stage instead of falling through to Thinking', async () => {
+    const user = userEvent.setup();
+    const gate = deferred();
+    const transport = makeTransport({
+      chat: async function* () {
+        yield { type: 'stage', stage: 'prompt' } as const;
+        await gate.promise;
+        yield { type: 'sql', sql: 'SELECT 1' } as const;
+        yield { type: 'done' } as const;
+      },
+    });
+    render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    expect(await screen.findByText(/Building the prompt/)).toBeTruthy();
+    gate.resolve();
+    await waitFor(() => expect(screen.getByText('SELECT 1')).toBeTruthy());
+  });
+
+  it('reports a cancelled turn in the words on the button', async () => {
+    const user = userEvent.setup();
+    const transport = makeTransport({
+      chat: async function* (params: AskParams) {
+        yield { type: 'stage', stage: 'llm' } as const;
+        await new Promise((_, reject) => {
+          params.signal?.addEventListener('abort', () => {
+            const e = new Error('aborted');
+            e.name = 'AbortError';
+            reject(e);
+          });
+        });
+      },
+    });
+    render();
+    await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    const cancel = await screen.findByRole('button', { name: 'Cancel' });
+    expect(cancel.textContent).toBe('Cancel');
+    await user.click(cancel);
+    expect(await screen.findByText('Cancelled.')).toBeTruthy();
+  });
+
   it('says nothing when the model set its own limit', async () => {
     const user = userEvent.setup();
     const transport = makeTransport({
@@ -468,6 +620,80 @@ describe('AskSqlChat', () => {
   });
 });
 
+/** The schema fallback runs when the model can't produce SQL and the option is on. */
+function schemaAnswerTransport(answer: string, proposedSql?: string) {
+  return makeTransport({
+    chat: chatOf({ type: 'error', code: 'LLM_BAD_OUTPUT', userMessage: "couldn't build a query" }),
+    explainSchema: async () => ({
+      answer,
+      tables: [],
+      grounded: true,
+      unknownReferences: [],
+      isSchemaChange: false,
+      ...(proposedSql ? { proposedSql } : {}),
+    }),
+  });
+}
+
+async function askSchemaQuestion(
+  transport: ReturnType,
+  user: ReturnType,
+) {
+  const view = render();
+  await user.type(screen.getByRole('textbox', { name: /Ask a question/i }), 'q');
+  await user.click(screen.getByRole('button', { name: 'Send' }));
+  return view;
+}
+
+describe('schema answers', () => {
+  it('shows no error while the answer is still in flight', async () => {
+    const user = userEvent.setup();
+    const gate = deferred();
+    const transport = makeTransport({
+      chat: chatOf({ type: 'error', code: 'LLM_BAD_OUTPUT', userMessage: "couldn't build a query" }),
+      explainSchema: async () => {
+        await gate.promise;
+        return {
+          answer: 'orders links to customers via customer_id.',
+          tables: [],
+          grounded: true,
+          unknownReferences: [],
+          isSchemaChange: false,
+        };
+      },
+    });
+    await askSchemaQuestion(transport, user);
+
+    await waitFor(() => expect(screen.getByText(/Answering from your schema/i)).toBeTruthy());
+    expect(screen.queryByRole('alert')).toBeNull();
+
+    gate.resolve();
+    await waitFor(() => expect(screen.getByText(/customer_id/)).toBeTruthy());
+  });
+
+  it('offers a Copy control for a fenced block inside the answer', async () => {
+    const user = userEvent.setup();
+    const transport = schemaAnswerTransport('You could add it:\n```sql\nCREATE TABLE t (id int);\n```\nNothing ran.');
+    const { container } = await askSchemaQuestion(transport, user);
+
+    await waitFor(() => expect(container.querySelector('.asksql-explain .asksql-sqlblock')).toBeTruthy());
+    const block = container.querySelector('.asksql-explain .asksql-sqlblock') as HTMLElement;
+    expect(block.textContent).toContain('CREATE TABLE t (id int);');
+    expect(within(block).getByRole('button', { name: 'Copy' })).toBeTruthy();
+  });
+
+  it('shows a proposed query once when the answer repeats it in a fence', async () => {
+    const user = userEvent.setup();
+    const sql = 'SELECT id FROM orders';
+    const transport = schemaAnswerTransport(`Here it is:\n\`\`\`sql\n${sql}\n\`\`\`\nNothing ran.`, sql);
+    const { container } = await askSchemaQuestion(transport, user);
+
+    await waitFor(() => expect(screen.getByText(sql)).toBeTruthy());
+    expect([...container.querySelectorAll('pre')].filter((p) => p.textContent?.includes(sql))).toHaveLength(1);
+    expect(container.querySelector('.asksql-explain pre')).toBeNull();
+  });
+});
+
 describe('AskSqlBubble', () => {
   it('opens and closes the chat panel', async () => {
     const user = userEvent.setup();
@@ -555,6 +781,26 @@ describe('AskSqlBubble', () => {
   });
 });
 
+describe('question bubble', () => {
+  it('keeps the line breaks the user typed and wraps a token with no spaces', async () => {
+    const user = userEvent.setup();
+    const question = 'line one\nline two https://example.com/a/very/long/path/that/never/breaks/anywhere';
+    const transport = makeTransport({ chat: chatOf({ type: 'sql', sql: 'SELECT 1' }, { type: 'done' }) });
+    const { container } = render();
+    await user.type(
+      screen.getByRole('textbox', { name: /Ask a question/i }),
+      question.replace('\n', '{Shift>}{Enter}{/Shift}'),
+    );
+    await user.click(screen.getByRole('button', { name: 'Send' }));
+
+    const bubble = await waitFor(() => container.querySelector('.asksql-q') as HTMLElement);
+    expect(bubble.textContent).toBe(question);
+    const style = getComputedStyle(bubble);
+    expect(style.whiteSpace).toBe('pre-wrap');
+    expect(style.overflowWrap).toBe('anywhere');
+  });
+});
+
 describe('SqlBlock', () => {
   it('copies SQL and flips the button label', async () => {
     const user = userEvent.setup();
@@ -563,7 +809,25 @@ describe('SqlBlock', () => {
     render();
     await user.click(screen.getByRole('button', { name: 'Copy' }));
     expect(writeText).toHaveBeenCalledWith('SELECT 1');
-    expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy();
+    expect(await screen.findByRole('button', { name: 'Copied' })).toBeTruthy();
+  });
+
+  it('does not claim success when the clipboard write is rejected', async () => {
+    const user = userEvent.setup();
+    const writeText = vi.fn().mockRejectedValue(new Error('denied'));
+    Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+    render();
+    await user.click(screen.getByRole('button', { name: 'Copy' }));
+    expect(await screen.findByRole('button', { name: 'Copy failed' })).toBeTruthy();
+    expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull();
+  });
+
+  it('reports a failure when the page has no clipboard at all', async () => {
+    const user = userEvent.setup();
+    Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
+    render();
+    await user.click(screen.getByRole('button', { name: 'Copy' }));
+    expect(await screen.findByRole('button', { name: 'Copy failed' })).toBeTruthy();
   });
 });
 
@@ -579,6 +843,33 @@ describe('ResultTable', () => {
     expect(URL.createObjectURL).toHaveBeenCalled();
   });
 
+  it('confirms an export and keeps the object URL alive past the click', async () => {
+    const user = userEvent.setup();
+    (URL.revokeObjectURL as unknown as ReturnType).mockClear();
+    render();
+    await user.click(screen.getByRole('button', { name: 'Export CSV' }));
+    expect(await screen.findByRole('button', { name: 'Exported' })).toBeTruthy();
+    // Revoking in the same tick can cancel the download the click just started.
+    expect(URL.revokeObjectURL).not.toHaveBeenCalled();
+  });
+
+  it('copies the rows as CSV', async () => {
+    const user = userEvent.setup();
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+    render();
+    await user.click(screen.getByRole('button', { name: 'Copy' }));
+    expect(writeText).toHaveBeenCalledWith('region,total\nEU,100\nNA,250');
+  });
+
+  it('carries the full value of a clipped cell in its tooltip', () => {
+    const long = 'a-very-long-value-'.repeat(8);
+    render(
+      ,
+    );
+    expect(screen.getByText(long).getAttribute('title')).toBe(long);
+  });
+
   it('renders an empty state for zero rows', () => {
     const empty: ResultSet = { ...resultOf(), rows: [], rowCount: 0 };
     render();
diff --git a/packages/react/test/max-rows.test.tsx b/packages/react/test/max-rows.test.tsx
index 9382756..d452e42 100644
--- a/packages/react/test/max-rows.test.tsx
+++ b/packages/react/test/max-rows.test.tsx
@@ -52,6 +52,21 @@ describe('the row cap reaches the transport', () => {
     expect(seen[0]!.maxRows).toBe(42);
   });
 
+  it('picks up a raised cap on re-render, without waiting for the transport to change identity', async () => {
+    const { transport, seen } = recordingTransport();
+    const { result, rerender } = renderHook(({ maxRows }) => useAskSql({ transport, connectionId: 'db', maxRows }), {
+      initialProps: { maxRows: 100 },
+    });
+
+    rerender({ maxRows: 5000 });
+    await act(async () => {
+      await result.current.ask('how many orders');
+    });
+
+    await waitFor(() => expect(seen.length).toBeGreaterThan(0));
+    expect(seen[0]!.maxRows).toBe(5000);
+  });
+
   it('sends nothing when none is configured, so the server keeps its own cap', async () => {
     const { transport, seen } = recordingTransport();
     const { result } = renderHook(() => useAskSql({ transport, connectionId: 'db' }));
diff --git a/packages/react/test/useAskSql.test.tsx b/packages/react/test/useAskSql.test.tsx
index 508aa49..875ef46 100644
--- a/packages/react/test/useAskSql.test.tsx
+++ b/packages/react/test/useAskSql.test.tsx
@@ -97,7 +97,59 @@ describe('useAskSql', () => {
       await asking;
     });
     expect(result.current.turns[0]!.schemaAnswer).toBeUndefined();
-    expect(result.current.turns[0]!.phase).toBe('error');
+    expect(result.current.turns[0]!.phase).toBe('stopped');
+    expect(result.current.turns[0]!.error).toBeUndefined();
+  });
+
+  it('stays in a thinking state while the schema answer is on its way', async () => {
+    const gate = deferred();
+    const explainSchema = vi.fn(async () => {
+      await gate.promise;
+      return {
+        answer: 'orders links to customers via customer_id.',
+        tables: ['orders'],
+        grounded: true,
+        unknownReferences: [] as string[],
+        isSchemaChange: false,
+      };
+    });
+    const transport = makeTransport({
+      chat: chatOf({ type: 'error', code: 'LLM_BAD_OUTPUT', userMessage: "couldn't build a query" }),
+      explainSchema,
+    });
+    const { result } = renderHook(() => useAskSql({ transport, answerSchemaQuestions: true }));
+    let asking!: Promise;
+    act(() => {
+      asking = result.current.ask('how are the tables related?');
+    });
+    await waitFor(() => {
+      expect(explainSchema).toHaveBeenCalled();
+      expect(result.current.turns[0]!.stage).toBe('schema_answer');
+    });
+    expect(result.current.turns[0]!.phase).toBe('thinking');
+    expect(result.current.turns[0]!.error).toBeUndefined();
+
+    gate.resolve();
+    await act(async () => {
+      await asking;
+    });
+    expect(result.current.turns[0]!.phase).toBe('done');
+  });
+
+  it('restores the original error when the schema fallback itself fails', async () => {
+    const transport = makeTransport({
+      chat: chatOf({ type: 'error', code: 'LLM_BAD_OUTPUT', userMessage: "couldn't build a query" }),
+      explainSchema: async () => {
+        throw new Error('no schema answer either');
+      },
+    });
+    const { result } = renderHook(() => useAskSql({ transport, answerSchemaQuestions: true }));
+    await act(async () => {
+      await result.current.ask('how are the tables related?');
+    });
+    const turn = result.current.turns[0]!;
+    expect(turn.phase).toBe('error');
+    expect(turn.error?.userMessage).toBe("couldn't build a query");
   });
 
   it('does not fall back when answerSchemaQuestions is off', async () => {
@@ -531,6 +583,102 @@ describe('useAskSql', () => {
     expect(result.current.turns[0]!.phase).toBe('done');
   });
 
+  it('shows streamed tokens as progress, per attempt, and drops them once SQL arrives', async () => {
+    const first = deferred();
+    const second = deferred();
+    const transport = makeTransport({
+      chat: async function* (): AsyncIterable {
+        yield { type: 'stage', stage: 'llm' };
+        yield { type: 'token', text: 'SEL' };
+        yield { type: 'token', text: 'ECT' };
+        await first.promise;
+        yield { type: 'stage', stage: 'repair' };
+        yield { type: 'token', text: 'FIXED' };
+        await second.promise;
+        yield { type: 'sql', sql: 'SELECT 1' };
+        yield { type: 'done' };
+      },
+    });
+    const { result } = renderHook(() => useAskSql({ transport }));
+    let asking!: Promise;
+    act(() => {
+      asking = result.current.ask('q');
+    });
+    await waitFor(() => expect(result.current.turns[0]!.streamText).toBe('SELECT'));
+
+    first.resolve();
+    await waitFor(() => expect(result.current.turns[0]!.streamText).toBe('FIXED'));
+
+    second.resolve();
+    await act(async () => {
+      await asking;
+    });
+    expect(result.current.turns[0]!.streamText).toBeUndefined();
+    expect(result.current.turns[0]!.phase).toBe('done');
+  });
+
+  // A cancelled ask keeps running until its transport unwinds.
+  it('a cancelled turn neither paints nor erases the next turn stream', async () => {
+    const firstHold = deferred();
+    const tailToken = deferred();
+    const tailSql = deferred();
+    const transport = makeTransport({
+      chat: (params: AskParams): AsyncIterable =>
+        params.question === 'q1'
+          ? (async function* (): AsyncIterable {
+              yield { type: 'stage', stage: 'llm' };
+              yield { type: 'token', text: 'FIRST-TOKENS' };
+              // The transport ignores the abort; the stream stays open.
+              await firstHold.promise;
+            })()
+          : (async function* (): AsyncIterable {
+              yield { type: 'stage', stage: 'llm' };
+              yield { type: 'token', text: 'SECOND-' };
+              await tailToken.promise;
+              yield { type: 'token', text: 'TOKENS' };
+              await tailSql.promise;
+              yield { type: 'sql', sql: 'SELECT 2' };
+              yield { type: 'done' };
+            })(),
+    });
+    const { result } = renderHook(() => useAskSql({ transport }));
+
+    let asking1!: Promise;
+    act(() => {
+      asking1 = result.current.ask('q1');
+    });
+    await waitFor(() => expect(result.current.turns[0]!.streamText).toBe('FIRST-TOKENS'));
+
+    act(() => {
+      result.current.cancel();
+    });
+    let asking2!: Promise;
+    act(() => {
+      asking2 = result.current.ask('q2');
+    });
+    await waitFor(() => expect(result.current.turns[1]!.streamText).toBe('SECOND-'));
+    // Several flush ticks of the cancelled turn's timer.
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 3 * 80));
+    });
+    expect(result.current.turns[1]!.streamText).toBe('SECOND-');
+    expect(result.current.turns[0]!.streamText).not.toBe('SECOND-');
+
+    firstHold.resolve();
+    await act(async () => {
+      await asking1;
+    });
+    expect(result.current.turns[0]!.phase).toBe('stopped');
+
+    tailToken.resolve();
+    await waitFor(() => expect(result.current.turns[1]!.streamText).toBe('SECOND-TOKENS'));
+    tailSql.resolve();
+    await act(async () => {
+      await asking2;
+    });
+    expect(result.current.turns[1]!.sql).toBe('SELECT 2');
+  });
+
   it('reset clears the conversation', async () => {
     const transport = makeTransport({ chat: chatOf({ type: 'sql', sql: 'SELECT 1' }, { type: 'done' }) });
     const { result } = renderHook(() => useAskSql({ transport }));
diff --git a/packages/server/README.md b/packages/server/README.md
index a2b2bd5..84c071c 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -9,6 +9,9 @@ included.
 npm i @asksql/core @asksql/server @asksql/postgres pg express @ai-sdk/groq
 ```
 
+`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it
+for you, so name it explicitly as above.
+
 ## Run it as a server (no code)
 
 ```bash
diff --git a/packages/server/package.json b/packages/server/package.json
index 6063fda..b8d752c 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -23,7 +23,6 @@
     "build": "tsc -b"
   },
   "dependencies": {
-    "@asksql/core": "workspace:^",
     "@ai-sdk/openai-compatible": "^3.0.9"
   },
   "license": "Apache-2.0",
@@ -63,6 +62,7 @@
     "text2sql"
   ],
   "devDependencies": {
+    "@asksql/core": "workspace:>=0.6.0",
     "@asksql/postgres": "workspace:>=0.2.7",
     "@asksql/mysql": "workspace:>=0.2.6",
     "@asksql/oracle": "workspace:>=0.1.4",
@@ -71,6 +71,7 @@
     "@asksql/duckdb": "workspace:>=0.2.7"
   },
   "peerDependencies": {
+    "@asksql/core": "workspace:>=0.6.0",
     "@asksql/postgres": "workspace:>=0.1.0",
     "@asksql/mysql": "workspace:>=0.1.0",
     "@asksql/oracle": "workspace:>=0.1.0",
diff --git a/packages/sqlite/README.md b/packages/sqlite/README.md
index 1a17c93..03f5016 100644
--- a/packages/sqlite/README.md
+++ b/packages/sqlite/README.md
@@ -12,6 +12,9 @@ and reads it back, and refuses a database it cannot put into read-only mode.
 npm i @asksql/core @asksql/sqlite
 ```
 
+`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it
+for you, so name it explicitly as above.
+
 ```ts
 import { SqliteConnector } from '@asksql/sqlite';
 
diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json
index cf79599..a6ff48c 100644
--- a/packages/sqlite/package.json
+++ b/packages/sqlite/package.json
@@ -18,10 +18,8 @@
   "scripts": {
     "build": "tsc -b"
   },
-  "dependencies": {
-    "@asksql/core": "workspace:^"
-  },
   "peerDependencies": {
+    "@asksql/core": "workspace:>=0.6.0",
     "better-sqlite3": ">=11"
   },
   "peerDependenciesMeta": {
@@ -29,6 +27,9 @@
       "optional": true
     }
   },
+  "devDependencies": {
+    "@asksql/core": "workspace:>=0.6.0"
+  },
   "license": "Apache-2.0",
   "engines": {
     "node": ">=20"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eb8eb39..390d126 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -311,11 +311,10 @@ importers:
         version: 3.0.9(zod@4.4.3)
 
   packages/duckdb:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       '@duckdb/duckdb-wasm':
         specifier: ^1.32.0
         version: 1.32.0
@@ -324,51 +323,46 @@ importers:
         version: 1.5.4-r.1
 
   packages/mcp:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       '@modelcontextprotocol/sdk':
         specifier: ^1.29.0
         version: 1.29.0(zod@4.4.3)
 
   packages/mongodb:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       mongodb:
         specifier: ^6.10.0
         version: 6.21.0
 
   packages/mysql:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       mysql2:
         specifier: ^3.22.6
         version: 3.22.6(@types/node@26.1.1)
 
   packages/oracle:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       oracledb:
         specifier: ^6.5.0
         version: 6.10.0
 
   packages/postgres:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       '@types/pg':
         specifier: ^8.20.0
         version: 8.20.0
@@ -377,11 +371,10 @@ importers:
         version: 8.22.0
 
   packages/react:
-    dependencies:
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       '@types/react':
         specifier: ^19.2.17
         version: 19.2.17
@@ -400,10 +393,10 @@ importers:
       '@ai-sdk/openai-compatible':
         specifier: ^3.0.9
         version: 3.0.9(zod@4.4.3)
+    devDependencies:
       '@asksql/core':
-        specifier: workspace:^
+        specifier: workspace:>=0.6.0
         version: link:../core
-    devDependencies:
       '@asksql/duckdb':
         specifier: workspace:>=0.2.7
         version: link:../duckdb
@@ -425,12 +418,13 @@ importers:
 
   packages/sqlite:
     dependencies:
-      '@asksql/core':
-        specifier: workspace:^
-        version: link:../core
       better-sqlite3:
         specifier: '>=11'
         version: 12.11.1
+    devDependencies:
+      '@asksql/core':
+        specifier: workspace:>=0.6.0
+        version: link:../core
 
   packages/vscode:
     dependencies:
diff --git a/tools/packaged-consumer-test.mjs b/tools/packaged-consumer-test.mjs
index 316033a..296e34b 100644
--- a/tools/packaged-consumer-test.mjs
+++ b/tools/packaged-consumer-test.mjs
@@ -84,8 +84,9 @@ writeFileSync(
 );
 
 console.log(`installing into ${consumer} (nested, no hoisting)`);
-// --legacy-peer-deps stops npm auto-installing peers: only what a package truly declares as a
-// dependency is present, so an undeclared import has nothing to accidentally resolve against.
+// --legacy-peer-deps stops npm auto-installing peers, so an undeclared third-party import has
+// nothing to resolve against. @asksql imports are exempt: every @asksql tarball is installed at
+// the consumer root. tests/peer-install-conflict.test.ts covers the core peer instead.
 run(
   'npm',
   [