test: split cli tests from source tree (#216)

* feat(cli): define full warehouse dialect contract

* test(cli): keep dialect edge tests focused

* fix(cli): stabilize dialect contract foundation

* refactor(connectors): own read-only query preparation

* refactor(connectors): resolve dialects through registry

* refactor(connectors): keep concrete dialect classes internal

* chore(workspace): enforce dialect import boundary

* refactor(cli): resolve relationship dialect at scan boundary

* refactor(cli): use dialect display parsing for entity details

* refactor(cli): use dialect display parsing for warehouse catalog

* refactor(cli): use dialect SQL in relationship workflows

* test(cli): verify solid dialect scan workflow closure

* test: split cli tests from source tree

* refactor(cli): standardize BigQuery scope listing

* feat(sqlite): implement connector scope listing

* test(connectors): cover required table listing

* feat(cli): add warehouse driver registry

* refactor(setup): route scope discovery through driver registry

* refactor(cli): route local query execution through driver registry

* refactor(historic-sql): route dialect support through driver registry

* refactor(cli): test warehouse connections through driver registry

* fix(cli): close driver registry type export gaps

* Improve setup daemon diagnostics

* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback

Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.

* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match

The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.

Align the picker boundary with the canonical 3-level KtxTableRef:

- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
  resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
  (resolveEnabledTables already accepts the 3-part shape) and
  schemasFromEnabledTables now goes through parseDottedTableEntry so it
  recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
  reuse.

Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).

* fix(cli): allow debug telemetry under opt-out env
This commit is contained in:
Andrey Avtomonov 2026-05-26 08:49:05 +02:00 committed by GitHub
parent 924868841d
commit 56985b7e09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
548 changed files with 5048 additions and 2228 deletions

View file

@ -0,0 +1,275 @@
import type { Tool } from 'ai';
import { describe, expect, it } from 'vitest';
import type { StageIndex } from '../../../../src/context/ingest/stages/stage-index.types.js';
import { createEmitArtifactResolutionTool } from '../../../../src/context/ingest/tools/emit-artifact-resolution.tool.js';
import { createEmitConflictResolutionTool } from '../../../../src/context/ingest/tools/emit-conflict-resolution.tool.js';
import { createEmitEvictionDecisionTool } from '../../../../src/context/ingest/tools/emit-eviction-decision.tool.js';
import { createEmitUnmappedFallbackTool } from '../../../../src/context/ingest/tools/emit-unmapped-fallback.tool.js';
function makeStageIndex(): StageIndex {
return {
jobId: 'job-1',
connectionId: 'c1',
workUnits: [],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
};
}
async function executeTool<Input>(tool: Tool<Input, string>, input: NoInfer<Input>) {
if (!tool.execute) {
throw new Error('tool is not executable');
}
return (await tool.execute(input, { toolCallId: 'tool-call-1', messages: [] })) as string;
}
describe('reconciliation emit tools', () => {
it('records conflict resolutions on the shared stage index', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitConflictResolutionTool({ stageIndex });
const output = await executeTool(tool, {
unitKey: 'wu-orders',
kind: 'near_duplicate',
contestedKey: 'gross_revenue',
artifactKey: 'sl:orders.gross_revenue',
detail: 'orders and order_facts compute the same revenue metric; retained orders as canonical',
flaggedForHuman: true,
});
expect(stageIndex.conflictsResolved).toEqual([
{
unitKey: 'wu-orders',
kind: 'near_duplicate',
contestedKey: 'gross_revenue',
artifactKey: 'sl:orders.gross_revenue',
detail: 'orders and order_facts compute the same revenue metric; retained orders as canonical',
flaggedForHuman: true,
},
]);
expect(output).toBe('recorded conflict resolution for sl:orders.gross_revenue');
});
it('records eviction decisions only for deleted raw paths in the current eviction set', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitEvictionDecisionTool({
stageIndex,
deletedRawPaths: ['views/old_orders.view.lkml'],
});
const output = await executeTool(tool, {
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'sl',
artifactKey: 'old_orders',
action: 'removed',
reason: 'source raw file was deleted and no retained artifacts are required',
});
expect(output).toContain('recorded eviction decision for views/old_orders.view.lkml');
expect(stageIndex.evictionsApplied).toEqual([
{
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'sl',
artifactKey: 'old_orders',
action: 'removed',
reason: 'source raw file was deleted and no retained artifacts are required',
},
]);
});
it('updates an existing eviction decision for the same raw path and artifact', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitEvictionDecisionTool({
stageIndex,
deletedRawPaths: ['views/old_orders.view.lkml'],
});
await executeTool(tool, {
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'wiki',
artifactKey: 'orders/old',
action: 'removed',
reason: 'first pass',
});
await executeTool(tool, {
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'wiki',
artifactKey: 'orders/old',
action: 'removed',
reason: 'second pass after checking references',
});
expect(stageIndex.evictionsApplied).toEqual([
{
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'wiki',
artifactKey: 'orders/old',
action: 'removed',
reason: 'second pass after checking references',
},
]);
});
it('rejects eviction decisions for raw paths outside the current eviction set', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitEvictionDecisionTool({
stageIndex,
deletedRawPaths: ['views/old_orders.view.lkml'],
});
const output = await executeTool(tool, {
rawPath: 'views/not_deleted.view.lkml',
artifactKind: 'sl',
artifactKey: 'not_deleted',
action: 'removed',
reason: 'bad input',
});
expect(output).toContain('Error: rawPath "views/not_deleted.view.lkml" is not in the current eviction set');
expect(stageIndex.evictionsApplied).toEqual([]);
});
it('records unmapped fallback decisions for allowed raw paths', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['metrics/conversion.yml']),
});
const output = await executeTool(tool, {
rawPath: 'metrics/conversion.yml',
reason: 'no_physical_table',
fallback: 'flagged',
});
expect(output).toContain('recorded unmapped fallback for metrics/conversion.yml');
expect(stageIndex.unmappedFallbacks).toEqual([
{
rawPath: 'metrics/conversion.yml',
reason: 'no_physical_table',
detail: expect.stringContaining('not present as a source'),
fallback: 'flagged',
},
]);
});
it('deduplicates identical unmapped fallback decisions', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['metrics/conversion.yml']),
});
await executeTool(tool, {
rawPath: 'metrics/conversion.yml',
reason: 'no_physical_table',
fallback: 'flagged',
});
await executeTool(tool, {
rawPath: 'metrics/conversion.yml',
reason: 'no_physical_table',
fallback: 'flagged',
});
expect(stageIndex.unmappedFallbacks).toEqual([
{
rawPath: 'metrics/conversion.yml',
reason: 'no_physical_table',
detail: expect.stringContaining('not present as a source'),
fallback: 'flagged',
},
]);
});
it('records MetricFlow-specific unsupported fallback reasons', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['metrics/conversion.yml']),
});
const output = await executeTool(tool, {
rawPath: 'metrics/conversion.yml',
reason: 'conversion_metric_unsupported',
fallback: 'flagged',
});
expect(output).toContain('conversion metric');
expect(stageIndex.unmappedFallbacks).toEqual([
{
rawPath: 'metrics/conversion.yml',
reason: 'conversion_metric_unsupported',
detail: expect.stringContaining('conversion metric'),
fallback: 'flagged',
},
]);
});
it('rejects unmapped fallback decisions for raw paths outside the allowed set', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['metrics/conversion.yml']),
});
const output = await executeTool(tool, {
rawPath: 'metrics/not-in-this-work-unit.yml',
reason: 'no_physical_table',
fallback: 'flagged',
});
expect(output).toContain(
'Error: rawPath "metrics/not-in-this-work-unit.yml" is not available to this ingest stage',
);
expect(stageIndex.unmappedFallbacks).toEqual([]);
});
it('rejects missing-table fallback decisions when the table resolves to an existing semantic source', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['cards/revenue.json']),
tableRefExists: async (tableRef) => tableRef === 'orbit_analytics.mart_revenue_daily',
});
const output = await executeTool(tool, {
rawPath: 'cards/revenue.json',
reason: 'no_physical_table',
tableRef: 'orbit_analytics.mart_revenue_daily',
fallback: 'wiki_only',
});
expect(output).toContain(
'Error: tableRef "orbit_analytics.mart_revenue_daily" already resolves to a semantic source',
);
expect(stageIndex.unmappedFallbacks).toEqual([]);
});
it('records explicit artifact resolutions for provenance rows', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitArtifactResolutionTool({
stageIndex,
allowedPaths: new Set(['explores/b2b/sales_pipeline.json']),
});
const output = await executeTool(tool, {
rawPath: 'explores/b2b/sales_pipeline.json',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
actionType: 'subsumed',
reason: 'File-adapter source b2b__sales_pipeline is canonical for this explore.',
});
expect(output).toBe('recorded artifact resolution for sl:looker__b2b__sales_pipeline');
expect(stageIndex.artifactResolutions).toEqual([
{
rawPath: 'explores/b2b/sales_pipeline.json',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
actionType: 'subsumed',
reason: 'File-adapter source b2b__sales_pipeline is canonical for this explore.',
},
]);
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { createEvictionListTool } from '../../../../src/context/ingest/tools/eviction-list.tool.js';
describe('eviction_list tool', () => {
it('returns artifacts produced for each deleted raw path', async () => {
const provenance = {
findLatestArtifactsForRawPaths: vi.fn().mockResolvedValue(
new Map([
[
'views/old.lkml',
[{ artifact_kind: 'sl', artifact_key: 'old_metric', action_type: 'source_created' } as any],
],
['views/gone.lkml', []],
]),
),
};
const tool = createEvictionListTool({
provenance: provenance as any,
connectionId: 'c1',
sourceKey: 'lookml',
deletedRawPaths: ['views/old.lkml', 'views/gone.lkml'],
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{},
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toContain('views/old.lkml');
expect(out).toContain('old_metric');
expect(out).toContain('views/gone.lkml');
});
it('returns empty string when no deletions', async () => {
const tool = createEvictionListTool({
provenance: {} as any,
connectionId: 'c1',
sourceKey: 'lookml',
deletedRawPaths: [],
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{},
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toMatch(/empty/i);
});
it('tells curators to record decisions', () => {
const tool = createEvictionListTool({
provenance: {} as any,
connectionId: 'c1',
sourceKey: 'lookml',
deletedRawPaths: [],
});
expect(tool.description).toContain('emit_eviction_decision');
});
});

View file

@ -0,0 +1,69 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createReadRawFileTool } from '../../../../src/context/ingest/tools/read-raw-file.tool.js';
describe('read_raw_file tool', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'readraw-'));
await mkdir(join(stagedDir, 'views'), { recursive: true });
await writeFile(join(stagedDir, 'views', 'a.yml'), 'line1\nline2\nline3\n', 'utf-8');
await writeFile(join(stagedDir, 'peer.yml'), 'secret', 'utf-8');
});
afterEach(async () => rm(stagedDir, { recursive: true, force: true }));
it('returns content for an allowed path', async () => {
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'views/a.yml' },
{ toolCallId: 't1', messages: [] },
);
expect(result).toContain('line1');
expect(result).toContain('line2');
});
it('refuses to return oversized files and directs callers to read spans', async () => {
await writeFile(join(stagedDir, 'views', 'huge.yml'), `${'x'.repeat(160_000)}\n`, 'utf-8');
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/huge.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'views/huge.yml' },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/too large/i);
expect(result).toMatch(/read_raw_span/i);
expect(String(result).length).toBeLessThan(1000);
});
it('rejects a path not in the allow-list', async () => {
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'peer.yml' },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/not accessible/i);
expect(result).not.toContain('secret');
});
it('rejects directory traversal attempts', async () => {
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: '../outside.yml' },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/not accessible/i);
});
it('returns a clear error when the file is missing despite being allowed', async () => {
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/missing.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'views/missing.yml' },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/not found/i);
});
});

View file

@ -0,0 +1,53 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createReadRawSpanTool } from '../../../../src/context/ingest/tools/read-raw-span.tool.js';
describe('read_raw_span tool', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'readspan-'));
await mkdir(join(stagedDir, 'v'), { recursive: true });
await writeFile(join(stagedDir, 'v', 'a.yml'), 'line1\nline2\nline3\nline4\nline5\n', 'utf-8');
});
afterEach(async () => rm(stagedDir, { recursive: true, force: true }));
it('returns the requested 1-based inclusive line range', async () => {
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'v/a.yml', startLine: 2, endLine: 4 },
{ toolCallId: 't1', messages: [] },
);
expect(result).toBe('line2\nline3\nline4');
});
it('clamps endLine to the end of the file', async () => {
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'v/a.yml', startLine: 4, endLine: 99 },
{ toolCallId: 't1', messages: [] },
);
expect(result).toBe('line4\nline5');
});
it('rejects start > end', async () => {
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'v/a.yml', startLine: 5, endLine: 2 },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/startLine must be/i);
});
it('rejects paths not in the allow-list', async () => {
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set([]) });
const result = await (tool.execute as (...args: unknown[]) => unknown)(
{ path: 'v/a.yml', startLine: 1, endLine: 1 },
{ toolCallId: 't1', messages: [] },
);
expect(result).toMatch(/not accessible/i);
});
});

View file

@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { createStageDiffTool } from '../../../../src/context/ingest/tools/stage-diff.tool.js';
describe('stage_diff tool', () => {
const stageIndex = {
jobId: 'j',
connectionId: 'c1',
workUnits: [
{
unitKey: 'u1',
rawFiles: [],
status: 'success' as const,
actions: [{ target: 'sl' as const, type: 'created' as const, key: 'churn_risk_score', detail: 'customers' }],
touchedSlSources: [{ connectionId: 'c1', sourceName: 'customers' }],
},
{
unitKey: 'u2',
rawFiles: [],
status: 'success' as const,
actions: [{ target: 'sl' as const, type: 'created' as const, key: 'churn_risk_score', detail: 'billing' }],
touchedSlSources: [{ connectionId: 'c1', sourceName: 'billing' }],
},
],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
};
it('finds overlapping artifact keys between two WUs', async () => {
const tool = createStageDiffTool({ stageIndex });
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{ unitKeyA: 'u1', unitKeyB: 'u2' },
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toContain('churn_risk_score');
expect(out).toMatch(/overlap/i);
});
it('says no overlap when keys are disjoint', async () => {
const tool = createStageDiffTool({
stageIndex: {
jobId: 'j',
connectionId: 'c1',
workUnits: [
{
unitKey: 'u1',
rawFiles: [],
status: 'success',
actions: [{ target: 'sl', type: 'created', key: 'a', detail: '' }],
touchedSlSources: [{ connectionId: 'c1', sourceName: 'a' }],
},
{
unitKey: 'u2',
rawFiles: [],
status: 'success',
actions: [{ target: 'sl', type: 'created', key: 'b', detail: '' }],
touchedSlSources: [{ connectionId: 'c1', sourceName: 'b' }],
},
],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
},
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{ unitKeyA: 'u1', unitKeyB: 'u2' },
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toMatch(/no overlap/i);
});
it('does not overlap same-named SL actions on different target connections', async () => {
const tool = createStageDiffTool({
stageIndex: {
jobId: 'j',
connectionId: 'looker-run',
workUnits: [
{
unitKey: 'u1',
rawFiles: [],
status: 'success',
actions: [
{
target: 'sl',
type: 'created',
key: 'looker__b2b__sales_pipeline',
detail: 'W1',
targetConnectionId: 'W1',
},
],
touchedSlSources: [{ connectionId: 'W1', sourceName: 'looker__b2b__sales_pipeline' }],
},
{
unitKey: 'u2',
rawFiles: [],
status: 'success',
actions: [
{
target: 'sl',
type: 'created',
key: 'looker__b2b__sales_pipeline',
detail: 'W2',
targetConnectionId: 'W2',
},
],
touchedSlSources: [{ connectionId: 'W2', sourceName: 'looker__b2b__sales_pipeline' }],
},
],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
},
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{ unitKeyA: 'u1', unitKeyB: 'u2' },
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toMatch(/no overlap/i);
});
it('returns an error when a unitKey is unknown', async () => {
const tool = createStageDiffTool({ stageIndex });
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{ unitKeyA: 'u1', unitKeyB: 'nope' },
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toMatch(/unknown/i);
});
});

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { createStageListTool } from '../../../../src/context/ingest/tools/stage-list.tool.js';
describe('stage_list tool', () => {
it('returns a compact summary of the stage index', async () => {
const tool = createStageListTool({
stageIndex: {
jobId: 'j1',
connectionId: 'c1',
workUnits: [
{
unitKey: 'u1',
rawFiles: ['a.yml'],
status: 'success',
actions: [{ target: 'sl', type: 'created', key: 'src_a', detail: '' }],
touchedSlSources: [{ connectionId: 'c1', sourceName: 'src_a' }],
},
{
unitKey: 'u2',
rawFiles: ['b.yml'],
status: 'success',
actions: [
{
target: 'wiki',
type: 'created',
key: 'page_b',
detail: 'tables: orbit_analytics.customer',
},
],
touchedSlSources: [],
},
],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
},
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{},
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toContain('u1');
expect(out).toContain('src_a');
expect(out).toContain('u2');
expect(out).toContain('page_b');
expect(out).toContain('tables: orbit_analytics.customer');
});
it('says empty when no writes', async () => {
const tool = createStageListTool({
stageIndex: {
jobId: 'j',
connectionId: 'c1',
workUnits: [],
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
},
});
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
{},
{ toolCallId: 't', messages: [] },
)) as string;
expect(out).toMatch(/empty/i);
});
});

View file

@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
import type { ToolCallLogEntry } from '../../../../src/context/ingest/tools/tool-call-logger.js';
import { createMutableToolTranscriptSummary, recordToolTranscriptEntry } from '../../../../src/context/ingest/tools/tool-transcript-summary.js';
function entry(overrides: Partial<ToolCallLogEntry>): ToolCallLogEntry {
return {
ts: '2026-05-11T00:00:00.000Z',
wuKey: 'wu-1',
toolName: 'wiki_write',
durationMs: 1,
input: {},
...overrides,
};
}
describe('tool transcript summaries', () => {
it('keeps recovered wiki_write structured failures out of fatal failures', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'orbit-customers' },
output: { structured: { success: false, key: 'orbit-customers' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'orbit-customers' },
output: { structured: { success: true, key: 'orbit-customers' } },
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(0);
});
it('treats a suggested flat wiki key retry as recovery for an invalid nested key', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'historic-sql/top-accounts-by-contract-arr' },
output: { structured: { success: false, key: 'historic-sql/top-accounts-by-contract-arr' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'historic-sql-top-accounts-by-contract-arr' },
output: { structured: { success: true, key: 'historic-sql-top-accounts-by-contract-arr' } },
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(0);
});
it('counts unrecovered wiki_remove structured failures as fatal transcript errors', () => {
const summary = createMutableToolTranscriptSummary('reconcile', '/tmp/reconcile.jsonl');
recordToolTranscriptEntry(summary, {
ts: '2026-05-11T00:00:00.000Z',
wuKey: 'reconcile',
toolCallId: 'remove-1',
toolName: 'wiki_remove',
durationMs: 1,
input: { key: 'duplicate-page' },
output: { structured: { success: false, key: 'duplicate-page' } },
});
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(1);
});
it('keeps unrecovered structured write failures fatal', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'orbit-customers' },
output: { structured: { success: false, key: 'orbit-customers' } },
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(1);
});
it('treats a later sl_edit_source success as recovery for the same SL source', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_write_source',
input: { connectionId: 'warehouse', sourceName: 'orbit_customers' },
output: { structured: { success: false, sourceName: 'orbit_customers' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_edit_source',
input: { connectionId: 'warehouse', sourceName: 'orbit_customers' },
output: { structured: { success: true, sourceName: 'orbit_customers' } },
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(0);
});
it('treats explicit unmapped fallback as recovery for guarded SL write failures', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_write_source',
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
output: { structured: { success: false, sourceName: 'stg_accounts' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
toolName: 'emit_unmapped_fallback',
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', tableRef: 'stg_accounts', fallback: 'wiki_only' },
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(0);
});
it('treats an untargeted unmapped fallback as recovery when there is only one pending SL failure', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_write_source',
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
output: { structured: { success: false, sourceName: 'stg_accounts' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
toolName: 'emit_unmapped_fallback',
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', fallback: 'wiki_only' },
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(0);
});
it('keeps unrelated SL write failures fatal when one source gets an unmapped fallback', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_write_source',
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
output: { structured: { success: false, sourceName: 'stg_accounts' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
toolName: 'sl_write_source',
input: { connectionId: 'dbt-main', sourceName: 'stg_orders' },
output: { structured: { success: false, sourceName: 'stg_orders' } },
}),
);
recordToolTranscriptEntry(
summary,
entry({
toolName: 'emit_unmapped_fallback',
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', tableRef: 'stg_accounts', fallback: 'wiki_only' },
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
}),
);
expect(summary.errorCount).toBe(2);
expect(summary.fatalErrorCount).toBe(1);
});
it('keeps thrown tool errors fatal even after a successful write', () => {
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'orbit-customers' },
error: { message: 'tool crashed' },
}),
);
recordToolTranscriptEntry(
summary,
entry({
input: { key: 'orbit-customers' },
output: { structured: { success: true, key: 'orbit-customers' } },
}),
);
expect(summary.errorCount).toBe(1);
expect(summary.fatalErrorCount).toBe(1);
});
});

View file

@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js';
import type { BaseTool, ToolContext } from '../../../../../src/context/tools/base-tool.js';
import { DiscoverDataTool } from '../../../../../src/context/ingest/tools/warehouse-verification/discover-data.tool.js';
describe('DiscoverDataTool', () => {
const wikiSearchTool = { call: vi.fn() } as unknown as BaseTool & { call: ReturnType<typeof vi.fn> };
const slDiscoverTool = { call: vi.fn() } as unknown as BaseTool & { call: ReturnType<typeof vi.fn> };
const catalog = { searchByName: vi.fn() } as unknown as WarehouseCatalogService & {
searchByName: ReturnType<typeof vi.fn>;
};
const context: ToolContext = {
sourceId: 'ingest',
messageId: 'm1',
userId: 'system',
session: { allowedConnectionNames: new Set(['warehouse']) } as any,
};
const tool = new DiscoverDataTool({
wikiSearchTool,
slDiscoverTool,
catalogFactory: () => catalog,
});
beforeEach(() => {
wikiSearchTool.call.mockReset();
slDiscoverTool.call.mockReset();
catalog.searchByName.mockReset();
wikiSearchTool.call.mockResolvedValue({
markdown: '- orders wiki',
structured: { totalFound: 1, results: [{ key: 'orders' }] },
});
slDiscoverTool.call.mockResolvedValue({
markdown: '- orders source',
structured: { totalSources: 1, sources: [{ sourceName: 'orders' }] },
});
catalog.searchByName.mockResolvedValue([
{
kind: 'table',
connectionId: 'warehouse',
ref: { catalog: null, db: 'public', name: 'orders' },
display: 'public.orders',
matchedOn: 'name',
},
]);
});
it('groups wiki, semantic layer, and raw schema hits with routing hints', async () => {
const result = await tool.call({ query: 'orders', connectionId: 'warehouse', limit: 5 }, context);
expect(result.markdown).toContain('## Wiki Pages');
expect(result.markdown).toContain('use `wiki_read(blockKey)` for full content');
expect(result.markdown).toContain('## Semantic Layer Sources');
expect(result.markdown).toContain('use `sl_read_source(sourceName)` for the YAML');
expect(result.markdown).toContain('## Raw Warehouse Schema');
expect(result.markdown).toContain('use `entity_details({connectionId, targets: [{display}]})`');
expect(result.structured.raw?.hits).toHaveLength(1);
});
it('includes connectionId on raw schema hits so entity_details can follow up', async () => {
const multiConnectionContext: ToolContext = {
...context,
session: { allowedConnectionNames: new Set(['warehouse', 'analytics']) } as any,
};
catalog.searchByName.mockImplementation(async (connectionId: string, query: string) => [
{
kind: 'table',
connectionId,
ref: { catalog: null, db: 'public', name: `${connectionId}_${query}` },
display: `public.${connectionId}_${query}`,
matchedOn: 'name',
},
]);
const result = await tool.call({ query: 'orders', limit: 10 }, multiConnectionContext);
expect(catalog.searchByName).toHaveBeenCalledWith('analytics', 'orders', 10);
expect(catalog.searchByName).toHaveBeenCalledWith('warehouse', 'orders', 10);
expect(result.markdown).toContain('connectionId=analytics');
expect(result.markdown).toContain('connectionId=warehouse');
expect(result.markdown).toContain(
'entity_details({connectionId: "analytics", targets: [{display: "public.analytics_orders"}]})',
);
expect(result.structured.raw?.hits.map((hit) => hit.connectionId)).toEqual(['analytics', 'warehouse']);
});
it('refuses explicit out-of-scope connection names', async () => {
const result = await tool.call({ query: 'orders', connectionId: 'billing' }, context);
expect(result.markdown).toContain('Connection "billing" is not available to this ingest stage.');
expect(result.structured).toEqual({ wiki: null, sl: null, raw: null });
expect(wikiSearchTool.call).not.toHaveBeenCalled();
expect(slDiscoverTool.call).not.toHaveBeenCalled();
expect(catalog.searchByName).not.toHaveBeenCalled();
});
it('delegates sourceName inspect mode to sl_discover only', async () => {
slDiscoverTool.call.mockResolvedValueOnce({
markdown: 'source detail',
structured: { sourceName: 'orders' },
});
const result = await tool.call({ sourceName: 'orders', connectionId: 'warehouse' }, context);
expect(slDiscoverTool.call).toHaveBeenCalledWith({ sourceName: 'orders', connectionId: 'warehouse' }, context);
expect(wikiSearchTool.call).not.toHaveBeenCalled();
expect(catalog.searchByName).not.toHaveBeenCalled();
expect(result.markdown).toContain('source detail');
});
it('returns the empty-state message when all sections are empty', async () => {
wikiSearchTool.call.mockResolvedValueOnce({ markdown: '', structured: { totalFound: 0, results: [] } });
slDiscoverTool.call.mockResolvedValueOnce({ markdown: '', structured: { totalSources: 0, sources: [] } });
catalog.searchByName.mockResolvedValueOnce([]);
const result = await tool.call({ query: 'customer source', connectionId: 'warehouse' }, context);
expect(result.markdown).toContain('No matches for "customer source" across wiki, semantic layer, or raw warehouse schema.');
});
it('uses connectionId as the optional connection filter', () => {
const legacyConnectionField = ['connection', 'Name'].join('');
expect(tool.parseInput({ query: 'orders', connectionId: 'warehouse', limit: 5 })).toEqual({
query: 'orders',
connectionId: 'warehouse',
limit: 5,
});
expect(() => tool.parseInput({ query: 'orders', [legacyConnectionField]: 'warehouse', limit: 5 })).toThrow();
});
});

View file

@ -0,0 +1,213 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { initKtxProject, type KtxLocalProject } from '../../../../../src/context/project/project.js';
import { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js';
import type { ToolContext } from '../../../../../src/context/tools/base-tool.js';
import { EntityDetailsTool } from '../../../../../src/context/ingest/tools/warehouse-verification/entity-details.tool.js';
describe('EntityDetailsTool', () => {
let tempDir: string;
let project: KtxLocalProject;
let tool: EntityDetailsTool;
let context: ToolContext;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-entity-details-'));
project = await initKtxProject({ projectDir: join(tempDir, 'project') });
await seedLiveDatabaseScan();
tool = new EntityDetailsTool(() => new WarehouseCatalogService({ fileStore: project.fileStore }));
context = {
sourceId: 'ingest',
messageId: 'm1',
userId: 'system',
session: {
allowedConnectionNames: new Set(['warehouse']),
} as any,
};
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
async function seedLiveDatabaseScan(connectionId = 'warehouse', syncId = 'sync-1') {
const root = `raw-sources/${connectionId}/live-database/${syncId}`;
await project.fileStore.writeFile(
`${root}/connection.json`,
JSON.stringify({ connectionId, driver: 'postgres', extractedAt: '2026-05-12T00:00:00.000Z' }, null, 2),
'ktx',
'ktx@example.com',
'seed connection',
);
await project.fileStore.writeFile(
`${root}/tables/orders.json`,
JSON.stringify(
{
catalog: null,
db: 'public',
name: 'orders',
kind: 'table',
comment: 'Customer orders',
estimatedRows: 12,
columns: [
{
name: 'id',
nativeType: 'integer',
normalizedType: 'integer',
dimensionType: 'number',
nullable: false,
primaryKey: true,
comment: 'Order id',
},
{
name: 'status',
nativeType: 'text',
normalizedType: 'text',
dimensionType: 'string',
nullable: false,
primaryKey: false,
comment: 'Order status',
},
],
foreignKeys: [],
},
null,
2,
),
'ktx',
'ktx@example.com',
'seed orders',
);
await project.fileStore.writeFile(
`${root}/enrichment/relationship-profile.json`,
JSON.stringify(
{
connectionId,
driver: 'postgres',
tables: [{ table: { catalog: null, db: 'public', name: 'orders' }, rowCount: 12 }],
columns: {
'orders.status': {
table: { catalog: null, db: 'public', name: 'orders' },
column: 'status',
rowCount: 12,
nullCount: 0,
distinctCount: 2,
nullRate: 0,
sampleValues: ['paid', 'refunded'],
},
},
},
null,
2,
),
'ktx',
'ktx@example.com',
'seed profile',
);
}
it('returns scoped table detail for a display target', async () => {
const result = await tool.call({ connectionId: 'warehouse', targets: [{ display: 'public.orders' }] }, context);
expect(result.markdown).toContain('### public.orders');
expect(result.markdown).toContain('- status (text, nullable=false)');
expect(result.markdown).toContain('sample: ["paid","refunded"]');
expect(result.structured.scanAvailable).toBe(true);
expect(result.structured.resolved).toHaveLength(1);
});
it('resolves display targets that include a column name', async () => {
const result = await tool.call(
{ connectionId: 'warehouse', targets: [{ display: 'public.orders.status' }] },
context,
);
expect(result.markdown).toContain('### public.orders');
expect(result.markdown).toContain('- status (text, nullable=false)');
expect(result.markdown).not.toContain('- id (integer');
expect(result.structured.resolved).toHaveLength(1);
expect(result.structured.resolved[0]?.columns.map((column) => column.name)).toEqual(['status']);
});
it('reports missing explicit columns instead of returning an empty column list', async () => {
const result = await tool.call(
{ connectionId: 'warehouse', targets: [{ display: 'public.orders.plan_tier' }] },
context,
);
expect(result.markdown).toContain('Column not found in scan: public.orders.plan_tier');
expect(result.markdown).toContain('Available columns: id, status');
expect(result.structured.resolved).toHaveLength(0);
expect(result.structured.missing).toHaveLength(1);
});
it('reports missing structured table targets in model-visible markdown', async () => {
const result = await tool.call(
{
connectionId: 'warehouse',
targets: [{ catalog: null, db: 'public', name: 'orderz' }],
},
context,
);
expect(result.markdown).toContain('Not found in scan: public.orderz');
expect(result.markdown).toContain('Closest matches: orders');
expect(result.structured.resolved).toHaveLength(0);
expect(result.structured.missing).toHaveLength(1);
});
it('reports missing structured column targets in model-visible markdown', async () => {
const result = await tool.call(
{
connectionId: 'warehouse',
targets: [{ catalog: null, db: 'public', name: 'orders', column: 'plan_tier' }],
},
context,
);
expect(result.markdown).toContain('Column not found in scan: public.orders.plan_tier');
expect(result.markdown).toContain('Available columns: id, status');
expect(result.structured.resolved).toHaveLength(0);
expect(result.structured.missing).toHaveLength(1);
});
it('returns a no-scan state distinct from not found', async () => {
const result = await tool.call(
{ connectionId: 'empty', targets: [{ display: 'public.orders' }] },
{ ...context, session: { ...context.session!, allowedConnectionNames: new Set(['empty']) } },
);
expect(result.markdown).toContain('No live-database scan available for connection "empty"; run `ktx scan` first.');
expect(result.structured.scanAvailable).toBe(false);
});
it('refuses out-of-scope connections', async () => {
const result = await tool.call({ connectionId: 'billing', targets: [{ display: 'public.orders' }] }, context);
expect(result.markdown).toContain('Connection "billing" is not available to this ingest stage.');
expect(result.structured.scanAvailable).toBe(false);
});
it('uses connectionId as the public input field', async () => {
const legacyConnectionField = ['connection', 'Name'].join('');
expect(
tool.parseInput({
connectionId: 'warehouse',
targets: [{ display: 'public.orders' }],
}),
).toEqual({
connectionId: 'warehouse',
targets: [{ display: 'public.orders' }],
});
expect(() =>
tool.parseInput({
[legacyConnectionField]: 'warehouse',
targets: [{ display: 'public.orders' }],
}),
).toThrow();
});
});

View file

@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlConnectionCatalogPort } from '../../../../../src/context/sl/ports.js';
import type { ToolContext } from '../../../../../src/context/tools/base-tool.js';
import { SqlExecutionTool } from '../../../../../src/context/ingest/tools/warehouse-verification/sql-execution.tool.js';
describe('SqlExecutionTool', () => {
const connections = {
executeQuery: vi.fn(),
} as unknown as SlConnectionCatalogPort & { executeQuery: ReturnType<typeof vi.fn> };
const tool = new SqlExecutionTool(connections);
const context: ToolContext = {
sourceId: 'ingest',
messageId: 'm1',
userId: 'system',
session: { allowedConnectionNames: new Set(['warehouse']) } as any,
};
it('wraps read-only SQL with a capped row limit', async () => {
connections.executeQuery.mockResolvedValue({ headers: ['status'], rows: [['paid']], totalRows: 1 });
const result = await tool.call(
{ connectionId: 'warehouse', sql: 'select status from public.orders', rowLimit: 5 },
context,
);
expect(connections.executeQuery).toHaveBeenCalledWith(
'warehouse',
'select * from (select status from public.orders) as ktx_query_result limit 5',
);
expect(result.markdown).toContain('| status |');
expect(result.structured.wrappedSql).toContain('limit 5');
});
it.each(['insert into x values (1)', 'drop table x', 'vacuum'])('rejects mutating SQL: %s', async (sql) => {
connections.executeQuery.mockClear();
const result = await tool.call({ connectionId: 'warehouse', sql }, context);
expect(result.markdown).toContain('Only read-only SELECT/WITH queries can be executed locally.');
expect(connections.executeQuery).not.toHaveBeenCalled();
});
it('surfaces connector errors verbatim', async () => {
connections.executeQuery.mockRejectedValue(new Error('relation "orbit_analytics.customer" does not exist'));
const result = await tool.call(
{ connectionId: 'warehouse', sql: 'select 1 from orbit_analytics.customer', rowLimit: 1 },
context,
);
expect(result.markdown).toContain('relation "orbit_analytics.customer" does not exist');
expect(result.structured.error).toContain('relation "orbit_analytics.customer" does not exist');
});
it('uses connectionId as the public input field', () => {
const legacyConnectionField = ['connection', 'Name'].join('');
expect(
tool.parseInput({
connectionId: 'warehouse',
sql: 'select 1',
rowLimit: 5,
}),
).toEqual({
connectionId: 'warehouse',
sql: 'select 1',
rowLimit: 5,
});
expect(() =>
tool.parseInput({
[legacyConnectionField]: 'warehouse',
sql: 'select 1',
rowLimit: 5,
}),
).toThrow();
});
});