2026-05-16 12:06:34 +02:00
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { z } from 'zod';
|
|
|
|
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
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
2026-05-26 08:49:05 +02:00
|
|
|
import { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from '../../../src/context/llm/claude-code-runtime.js';
|
2026-05-16 12:06:34 +02:00
|
|
|
|
|
|
|
|
async function* stream(messages: SDKMessage[]): AsyncGenerator<SDKMessage, void> {
|
|
|
|
|
for (const message of messages) {
|
|
|
|
|
yield message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
function deferred<T>() {
|
|
|
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
|
|
|
const promise = new Promise<T>((innerResolve) => {
|
|
|
|
|
resolve = innerResolve;
|
|
|
|
|
});
|
|
|
|
|
return { promise, resolve };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 12:06:34 +02:00
|
|
|
function initMessage(overrides: Partial<Extract<SDKMessage, { type: 'system'; subtype: 'init' }>> = {}): Extract<
|
|
|
|
|
SDKMessage,
|
|
|
|
|
{ type: 'system'; subtype: 'init' }
|
|
|
|
|
> {
|
|
|
|
|
return {
|
|
|
|
|
type: 'system',
|
|
|
|
|
subtype: 'init',
|
|
|
|
|
apiKeySource: 'none' as never, // pragma: allowlist secret
|
|
|
|
|
claude_code_version: '0.3.142',
|
|
|
|
|
cwd: '/tmp/project',
|
|
|
|
|
tools: [],
|
|
|
|
|
mcp_servers: [],
|
|
|
|
|
model: 'claude-sonnet-4-6',
|
|
|
|
|
permissionMode: 'dontAsk',
|
|
|
|
|
slash_commands: [],
|
|
|
|
|
output_style: 'default',
|
|
|
|
|
skills: [],
|
|
|
|
|
plugins: [],
|
|
|
|
|
uuid: '00000000-0000-4000-8000-000000000001',
|
|
|
|
|
session_id: 'session-id',
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resultMessage(overrides: Partial<Extract<SDKMessage, { type: 'result' }>> = {}): Extract<
|
|
|
|
|
SDKMessage,
|
|
|
|
|
{ type: 'result' }
|
|
|
|
|
> {
|
|
|
|
|
return {
|
|
|
|
|
type: 'result',
|
|
|
|
|
subtype: 'success',
|
|
|
|
|
duration_ms: 1,
|
|
|
|
|
duration_api_ms: 1,
|
|
|
|
|
is_error: false,
|
|
|
|
|
num_turns: 1,
|
|
|
|
|
result: 'ok',
|
|
|
|
|
stop_reason: null,
|
|
|
|
|
total_cost_usd: 0,
|
|
|
|
|
usage: {} as never,
|
|
|
|
|
modelUsage: {},
|
|
|
|
|
permission_denials: [],
|
|
|
|
|
errors: [],
|
|
|
|
|
uuid: '00000000-0000-4000-8000-000000000002',
|
|
|
|
|
session_id: 'session-id',
|
|
|
|
|
...overrides,
|
|
|
|
|
} as Extract<SDKMessage, { type: 'result' }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('ClaudeCodeKtxLlmRuntime', () => {
|
|
|
|
|
it('passes isolation options and scrubbed env to text generation', async () => {
|
|
|
|
|
const query = vi.fn((_input: any) => stream([initMessage(), resultMessage({ result: 'hello' })]));
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello');
|
|
|
|
|
expect(query).toHaveBeenCalledWith({
|
|
|
|
|
prompt: 'say hello',
|
|
|
|
|
options: expect.objectContaining({
|
|
|
|
|
cwd: '/tmp/project',
|
|
|
|
|
model: 'claude-sonnet-4-6',
|
|
|
|
|
maxTurns: 1,
|
|
|
|
|
settingSources: [],
|
|
|
|
|
skills: [],
|
|
|
|
|
plugins: [],
|
|
|
|
|
tools: [],
|
2026-05-18 13:38:06 +02:00
|
|
|
managedSettings: {
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [],
|
|
|
|
|
},
|
|
|
|
|
strictMcpConfig: true,
|
2026-05-16 12:06:34 +02:00
|
|
|
allowedTools: [],
|
|
|
|
|
permissionMode: 'dontAsk',
|
|
|
|
|
persistSession: false,
|
|
|
|
|
env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
it('waits before Claude Code text generation and reports rate-limit events', async () => {
|
|
|
|
|
const waitForReady = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const report = vi.fn();
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
{
|
|
|
|
|
type: 'rate_limit_event',
|
|
|
|
|
rate_limit_info: {
|
|
|
|
|
status: 'allowed_warning',
|
|
|
|
|
resetsAt: new Date(2_000).toISOString(),
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 0.91,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ result: 'ok' }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok');
|
|
|
|
|
expect(waitForReady).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(report).toHaveBeenCalledWith({
|
|
|
|
|
provider: 'claude-subscription',
|
|
|
|
|
status: 'warning',
|
|
|
|
|
resetAtMs: 2_000,
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 0.91,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('maps numeric Claude Code reset times from SDK rate-limit events', async () => {
|
|
|
|
|
const report = vi.fn();
|
|
|
|
|
const resetAtMs = 1_700_000_000_000;
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
{
|
|
|
|
|
type: 'rate_limit_event',
|
|
|
|
|
rate_limit_info: {
|
|
|
|
|
status: 'rejected',
|
|
|
|
|
resetsAt: resetAtMs,
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 1,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ result: 'ok' }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report, maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok');
|
|
|
|
|
|
|
|
|
|
expect(report).toHaveBeenCalledWith({
|
|
|
|
|
provider: 'claude-subscription',
|
|
|
|
|
status: 'rejected',
|
|
|
|
|
resetAtMs,
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('retries a Claude Code query after an SDK rate-limit result error', async () => {
|
|
|
|
|
const waitForReady = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const report = vi.fn();
|
|
|
|
|
const resetAtMs = 1_700_000_000_000;
|
|
|
|
|
const query = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockReturnValueOnce(
|
|
|
|
|
stream([
|
|
|
|
|
{
|
|
|
|
|
type: 'rate_limit_event',
|
|
|
|
|
rate_limit_info: {
|
|
|
|
|
status: 'rejected',
|
|
|
|
|
resetsAt: resetAtMs,
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 1,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({
|
|
|
|
|
subtype: 'error_during_execution',
|
|
|
|
|
is_error: true,
|
|
|
|
|
result: '',
|
|
|
|
|
errors: ['rate limit retry budget exhausted'],
|
|
|
|
|
terminal_reason: 'model_error',
|
|
|
|
|
} as never),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.mockReturnValueOnce(stream([resultMessage({ result: 'ok' })]));
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok');
|
|
|
|
|
|
|
|
|
|
expect(query).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(waitForReady).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(report).toHaveBeenCalledWith({
|
|
|
|
|
provider: 'claude-subscription',
|
|
|
|
|
status: 'rejected',
|
|
|
|
|
resetAtMs,
|
|
|
|
|
rateLimitType: 'five_hour',
|
|
|
|
|
utilization: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports Claude Code api retry messages as warning signals', async () => {
|
|
|
|
|
const report = vi.fn();
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
{
|
|
|
|
|
type: 'system',
|
|
|
|
|
subtype: 'api_retry',
|
|
|
|
|
retry_delay_ms: 12_000,
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ result: 'ok' }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report, maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runtime.generateText({ role: 'default', prompt: 'hello' });
|
|
|
|
|
expect(report).toHaveBeenCalledWith({
|
|
|
|
|
provider: 'claude-subscription',
|
|
|
|
|
status: 'warning',
|
|
|
|
|
retryAfterMs: 12_000,
|
|
|
|
|
rateLimitType: 'api_retry',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes abort signals into Claude Code governor waits', async () => {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const waitForReady = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const query = vi.fn((_input: any) => stream([resultMessage({ result: 'ok' })]));
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady, report: vi.fn(), maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal })).resolves.toBe('ok');
|
|
|
|
|
|
|
|
|
|
expect(waitForReady).toHaveBeenCalledWith(controller.signal);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('interrupts an active Claude Code query when the abort signal fires', async () => {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const streamStarted = deferred<void>();
|
|
|
|
|
const releaseStream = deferred<void>();
|
|
|
|
|
const interrupt = vi.fn(() => releaseStream.resolve());
|
|
|
|
|
const queryResult = {
|
|
|
|
|
async *[Symbol.asyncIterator]() {
|
|
|
|
|
streamStarted.resolve();
|
|
|
|
|
await releaseStream.promise;
|
|
|
|
|
yield resultMessage({ result: 'ok' });
|
|
|
|
|
},
|
|
|
|
|
interrupt,
|
|
|
|
|
};
|
|
|
|
|
const query = vi.fn(() => queryResult as never);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const pending = runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal });
|
|
|
|
|
await streamStarted.promise;
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
|
|
|
|
await expect(pending).rejects.toThrow(/Aborted/);
|
|
|
|
|
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws abort before starting Claude Code query when the signal is already aborted', async () => {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
controller.abort();
|
|
|
|
|
const query = vi.fn((_input: any) => stream([resultMessage({ result: 'ok' })]));
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal })).rejects.toThrow(/Aborted/);
|
|
|
|
|
expect(query).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats an interrupted Claude Code stream with no result as abort', async () => {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const streamStarted = deferred<void>();
|
|
|
|
|
const releaseStream = deferred<void>();
|
|
|
|
|
const interrupt = vi.fn(() => releaseStream.resolve());
|
|
|
|
|
const queryResult = {
|
|
|
|
|
async *[Symbol.asyncIterator]() {
|
|
|
|
|
streamStarted.resolve();
|
|
|
|
|
await releaseStream.promise;
|
|
|
|
|
},
|
|
|
|
|
interrupt,
|
|
|
|
|
};
|
|
|
|
|
const query = vi.fn(() => queryResult as never);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const pending = runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal });
|
|
|
|
|
await streamStarted.promise;
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
|
|
|
|
await expect(pending).rejects.toThrow(/Aborted/);
|
|
|
|
|
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
fix(snowflake): unblock multi-schema ingest and relationship discovery (#204)
* feat(setup): drop redundant Snowflake schema prompt; fall back to free-text on listSchemas failure
Snowflake setup previously asked for a single schema as free text, then
ran a multiselect against the discovered schemas — two schema questions
back-to-back, with the first being only a session bootstrap. The SDK's
`schema` is optional, so the bootstrap step is unnecessary.
- Remove the free-text Snowflake schema prompt; only pass `schema` to
snowflake-sdk when one is configured.
- When `listSchemas()` fails (e.g. role lacks SHOW SCHEMAS), prompt the
user for a comma-separated list, persist it as `schema_names`, and use
it as both the table-list filter and the multiselect default. Applies
to every driver with a scope-discovery spec, not just Snowflake.
- Update docs to lead with `schema_names`; keep `schema_name` as a
documented single-schema shorthand.
* fix(snowflake): keep introspecting when primary-key discovery is denied
The PK query joins INFORMATION_SCHEMA.TABLE_CONSTRAINTS and
INFORMATION_SCHEMA.KEY_COLUMN_USAGE, which require grants the
connection role may not have. Previously a 'SQL compilation error:
Object ANALYTICS.INFORMATION_SCHEMA.KEY_COLUMN_USAGE does not exist
or not authorized' aborted the entire introspect — schemas, columns,
and row counts were all discarded over a missing nice-to-have.
Wrap the constraint query in try/catch, log a one-line warning per
schema, and return an empty PK map. Columns end up with
primaryKey=false; relationship inference still has FK and profiling
to fall back on.
* fix(scan): unblock relationship discovery on Snowflake
Two adjacent bugs prevented the scan's relationship pipeline from producing
any joins on a Snowflake warehouse:
- relationship-profiling.ts fell through to a default `GROUP_CONCAT` branch
for unknown drivers. Snowflake has no GROUP_CONCAT, so every per-table
profile query failed with "Unknown function GROUP_CONCAT". Add an explicit
Snowflake branch that uses LISTAGG with a literal '\x1f' delimiter
(Snowflake requires the delimiter to be a constant, so CHR(31) is rejected).
- description-generation.ts destructured `connector.sampleTable` and
`connector.sampleColumn` into bare locals, losing the `this` binding when
the class-method connectors (Snowflake, Postgres, MySQL) were invoked.
Every sample call threw "Cannot read properties of undefined (reading
'assertConnection')" and degraded LLM descriptions to metadata-only
prompts. Call the methods through the connector instead.
Without these, even after the primary-key probe is allowed to fail softly,
the scan ends up with 0 validated relationships and an empty `joins:` block
in every shard YAML.
* test(scan): cover table-ref helpers
* feat(scan): plumb tableScope through live-database introspection port
* feat(scan): apply tableScope during metadata fetch
* feat(scan): enforce table scope at fetch boundary
* feat(scan): pool Snowflake sessions and batch enrichment for faster ingest (#206)
* feat(cli): add RSA key-pair auth option to Snowflake setup wizard
Extends the interactive Snowflake setup flow with an authentication-method
prompt (password vs RSA/JWT key-pair). The RSA branch collects a private-key
path (env/file/absolute) and an optional passphrase; the resulting connection
config records `authMethod: 'rsa'` with `privateKey` and `passphrase` instead
of `password`.
* feat(scan): pool Snowflake sessions
* fix(scan): reuse structural snapshots and cleanup connectors
* feat(scan): parallelize relationship profiling
* feat(scan): batch table description generation
* docs: document Snowflake ingest concurrency knobs
* fix(scan): close Snowflake ingest perf verification gaps
* fix(scan): keep batched description failure bounded
* feat(scan): dispatch query-history probes by connection driver
Extract historic-sql dialect resolution into a shared helper so the
status-project readiness check and the local ingest factory agree on
which connections enable query history and which probe to run. The
status command now picks the postgres/snowflake/bigquery probe based on
the connection's driver instead of always reporting against postgres,
which previously caused snowflake connections with queryHistory.enabled
to surface a misleading "driver is snowflake" failure.
Also drops a noisy console.warn from Snowflake primary-key discovery —
INFORMATION_SCHEMA.KEY_COLUMN_USAGE is commonly ungranted for read-only
roles and the FK + profiling paths handle the empty PK map already.
* fix(llm): allow StructuredOutput tool and raise maxTurns for generateObject
The Claude Code agent SDK announces an internal pseudo-tool named
StructuredOutput in the system/init message whenever outputFormat is set
to { type: 'json_schema' }. The runtime's isolation check built its
allowedToolIds set only from MCP tool ids and treated StructuredOutput
as an unexpected host-injected tool, so every generateObject call threw
"Claude Code runtime isolation failed: tools=StructuredOutput ..." and
the table-descriptions and relationship-LLM-proposal enrichment stages
recorded null output across the board.
Whitelist StructuredOutput specifically in generateObject's
allowedToolIds — the check also enforces missing_tools symmetry, so
generateText and runAgentLoop, which do not see StructuredOutput, must
not require it.
generateObject also ran with maxTurns: 1, which the model intermittently
breached when it emitted thinking text before the structured response.
Raised to 5 to give the schema-bound call enough headroom without
allowing unbounded loops. The existing tests now exercise the path with
an init message that announces StructuredOutput so the regression cannot
slip back in.
* chore(scripts): add ktx-reset.sh project-cleanup helper
Convenience script for repeatable ingest testing: takes a project
directory and prunes everything except ktx.yaml and .ktx/secrets/, so
the next ktx setup or ktx ingest run starts from a known-clean state.
2026-05-23 10:41:30 +02:00
|
|
|
it('validates structured output with the caller schema and whitelists the SDK StructuredOutput tool', async () => {
|
2026-05-16 12:06:34 +02:00
|
|
|
const schema = z.object({ answer: z.string() });
|
fix(snowflake): unblock multi-schema ingest and relationship discovery (#204)
* feat(setup): drop redundant Snowflake schema prompt; fall back to free-text on listSchemas failure
Snowflake setup previously asked for a single schema as free text, then
ran a multiselect against the discovered schemas — two schema questions
back-to-back, with the first being only a session bootstrap. The SDK's
`schema` is optional, so the bootstrap step is unnecessary.
- Remove the free-text Snowflake schema prompt; only pass `schema` to
snowflake-sdk when one is configured.
- When `listSchemas()` fails (e.g. role lacks SHOW SCHEMAS), prompt the
user for a comma-separated list, persist it as `schema_names`, and use
it as both the table-list filter and the multiselect default. Applies
to every driver with a scope-discovery spec, not just Snowflake.
- Update docs to lead with `schema_names`; keep `schema_name` as a
documented single-schema shorthand.
* fix(snowflake): keep introspecting when primary-key discovery is denied
The PK query joins INFORMATION_SCHEMA.TABLE_CONSTRAINTS and
INFORMATION_SCHEMA.KEY_COLUMN_USAGE, which require grants the
connection role may not have. Previously a 'SQL compilation error:
Object ANALYTICS.INFORMATION_SCHEMA.KEY_COLUMN_USAGE does not exist
or not authorized' aborted the entire introspect — schemas, columns,
and row counts were all discarded over a missing nice-to-have.
Wrap the constraint query in try/catch, log a one-line warning per
schema, and return an empty PK map. Columns end up with
primaryKey=false; relationship inference still has FK and profiling
to fall back on.
* fix(scan): unblock relationship discovery on Snowflake
Two adjacent bugs prevented the scan's relationship pipeline from producing
any joins on a Snowflake warehouse:
- relationship-profiling.ts fell through to a default `GROUP_CONCAT` branch
for unknown drivers. Snowflake has no GROUP_CONCAT, so every per-table
profile query failed with "Unknown function GROUP_CONCAT". Add an explicit
Snowflake branch that uses LISTAGG with a literal '\x1f' delimiter
(Snowflake requires the delimiter to be a constant, so CHR(31) is rejected).
- description-generation.ts destructured `connector.sampleTable` and
`connector.sampleColumn` into bare locals, losing the `this` binding when
the class-method connectors (Snowflake, Postgres, MySQL) were invoked.
Every sample call threw "Cannot read properties of undefined (reading
'assertConnection')" and degraded LLM descriptions to metadata-only
prompts. Call the methods through the connector instead.
Without these, even after the primary-key probe is allowed to fail softly,
the scan ends up with 0 validated relationships and an empty `joins:` block
in every shard YAML.
* test(scan): cover table-ref helpers
* feat(scan): plumb tableScope through live-database introspection port
* feat(scan): apply tableScope during metadata fetch
* feat(scan): enforce table scope at fetch boundary
* feat(scan): pool Snowflake sessions and batch enrichment for faster ingest (#206)
* feat(cli): add RSA key-pair auth option to Snowflake setup wizard
Extends the interactive Snowflake setup flow with an authentication-method
prompt (password vs RSA/JWT key-pair). The RSA branch collects a private-key
path (env/file/absolute) and an optional passphrase; the resulting connection
config records `authMethod: 'rsa'` with `privateKey` and `passphrase` instead
of `password`.
* feat(scan): pool Snowflake sessions
* fix(scan): reuse structural snapshots and cleanup connectors
* feat(scan): parallelize relationship profiling
* feat(scan): batch table description generation
* docs: document Snowflake ingest concurrency knobs
* fix(scan): close Snowflake ingest perf verification gaps
* fix(scan): keep batched description failure bounded
* feat(scan): dispatch query-history probes by connection driver
Extract historic-sql dialect resolution into a shared helper so the
status-project readiness check and the local ingest factory agree on
which connections enable query history and which probe to run. The
status command now picks the postgres/snowflake/bigquery probe based on
the connection's driver instead of always reporting against postgres,
which previously caused snowflake connections with queryHistory.enabled
to surface a misleading "driver is snowflake" failure.
Also drops a noisy console.warn from Snowflake primary-key discovery —
INFORMATION_SCHEMA.KEY_COLUMN_USAGE is commonly ungranted for read-only
roles and the FK + profiling paths handle the empty PK map already.
* fix(llm): allow StructuredOutput tool and raise maxTurns for generateObject
The Claude Code agent SDK announces an internal pseudo-tool named
StructuredOutput in the system/init message whenever outputFormat is set
to { type: 'json_schema' }. The runtime's isolation check built its
allowedToolIds set only from MCP tool ids and treated StructuredOutput
as an unexpected host-injected tool, so every generateObject call threw
"Claude Code runtime isolation failed: tools=StructuredOutput ..." and
the table-descriptions and relationship-LLM-proposal enrichment stages
recorded null output across the board.
Whitelist StructuredOutput specifically in generateObject's
allowedToolIds — the check also enforces missing_tools symmetry, so
generateText and runAgentLoop, which do not see StructuredOutput, must
not require it.
generateObject also ran with maxTurns: 1, which the model intermittently
breached when it emitted thinking text before the structured response.
Raised to 5 to give the schema-bound call enough headroom without
allowing unbounded loops. The existing tests now exercise the path with
an init message that announces StructuredOutput so the regression cannot
slip back in.
* chore(scripts): add ktx-reset.sh project-cleanup helper
Convenience script for repeatable ingest testing: takes a project
directory and prunes everything except ktx.yaml and .ktx/secrets/, so
the next ktx setup or ktx ingest run starts from a known-clean state.
2026-05-23 10:41:30 +02:00
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage({ tools: ['StructuredOutput'] }),
|
|
|
|
|
resultMessage({ structured_output: { answer: 'yes' } }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-05-16 12:06:34 +02:00
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({ answer: 'yes' });
|
|
|
|
|
expect(query.mock.calls[0][0].options.outputFormat).toMatchObject({
|
|
|
|
|
type: 'json_schema',
|
|
|
|
|
schema: expect.objectContaining({ type: 'object' }),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('registers only exact KTX MCP tool ids and denies non-KTX tools', async () => {
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }),
|
|
|
|
|
{
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
message: { role: 'assistant', content: [] },
|
|
|
|
|
parent_tool_use_id: null,
|
|
|
|
|
uuid: '00000000-0000-4000-8000-000000000003',
|
|
|
|
|
session_id: 'session-id',
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ subtype: 'error_max_turns', is_error: true }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runtime.runAgentLoop({
|
|
|
|
|
modelRole: 'default',
|
|
|
|
|
systemPrompt: 'system',
|
|
|
|
|
userPrompt: 'user',
|
|
|
|
|
toolSet: {
|
|
|
|
|
load_skill: {
|
|
|
|
|
name: 'load_skill',
|
|
|
|
|
description: 'Load skill.',
|
|
|
|
|
inputSchema: z.object({ name: z.string() }),
|
|
|
|
|
execute: async () => ({ markdown: 'loaded' }),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stepBudget: 1,
|
|
|
|
|
telemetryTags: { operationName: 'test' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const options = query.mock.calls[0][0].options;
|
|
|
|
|
expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']);
|
2026-05-18 13:38:06 +02:00
|
|
|
expect(options.managedSettings).toEqual({
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [{ serverName: 'ktx' }],
|
|
|
|
|
});
|
|
|
|
|
expect(options.strictMcpConfig).toBe(true);
|
2026-05-16 12:06:34 +02:00
|
|
|
expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({
|
|
|
|
|
behavior: 'allow',
|
|
|
|
|
toolUseID: '1',
|
|
|
|
|
});
|
|
|
|
|
expect(await options.canUseTool('Bash', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: '2',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats host-discovered commands skills and agents as non-fatal init metadata for text and auth probe', async () => {
|
|
|
|
|
const hostDiscoveredInit = initMessage({
|
|
|
|
|
slash_commands: ['/help', '/compact', '/clear', '/user-command'],
|
|
|
|
|
skills: ['pdf', 'docx'],
|
|
|
|
|
agents: ['claude', 'Explore', 'general-purpose'],
|
|
|
|
|
});
|
|
|
|
|
const textQuery = vi.fn((_input: any) => stream([hostDiscoveredInit, resultMessage({ result: 'hello' })]));
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query: textQuery,
|
|
|
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello');
|
|
|
|
|
const textOptions = textQuery.mock.calls[0][0].options;
|
|
|
|
|
expect(textOptions).toMatchObject({
|
|
|
|
|
settingSources: [],
|
|
|
|
|
skills: [],
|
|
|
|
|
plugins: [],
|
|
|
|
|
tools: [],
|
2026-05-18 13:38:06 +02:00
|
|
|
managedSettings: {
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [],
|
|
|
|
|
},
|
|
|
|
|
strictMcpConfig: true,
|
2026-05-16 12:06:34 +02:00
|
|
|
allowedTools: [],
|
|
|
|
|
permissionMode: 'dontAsk',
|
|
|
|
|
persistSession: false,
|
|
|
|
|
env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }),
|
|
|
|
|
});
|
|
|
|
|
expect(textOptions.disallowedTools).toEqual(expect.arrayContaining(['Agent', 'Task', 'Bash']));
|
|
|
|
|
expect(await textOptions.canUseTool('Agent', {}, { signal: new AbortController().signal, toolUseID: 'agent' })).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: 'agent',
|
|
|
|
|
});
|
|
|
|
|
expect(await textOptions.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: 'skill' })).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: 'skill',
|
|
|
|
|
});
|
|
|
|
|
expect(
|
|
|
|
|
await textOptions.canUseTool('SlashCommand', {}, { signal: new AbortController().signal, toolUseID: 'slash' }),
|
|
|
|
|
).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: 'slash',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const probeQuery = vi.fn((_input: any) => stream([hostDiscoveredInit, resultMessage({ result: 'ok' })]));
|
|
|
|
|
await expect(
|
|
|
|
|
runClaudeCodeAuthProbe({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
model: 'sonnet',
|
|
|
|
|
query: probeQuery,
|
|
|
|
|
env: { ANTHROPIC_AUTH_TOKEN: 'token', HOME: '/Users/test' },
|
|
|
|
|
}),
|
|
|
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
|
expect(probeQuery.mock.calls[0][0].options).toMatchObject({
|
|
|
|
|
settingSources: [],
|
|
|
|
|
skills: [],
|
|
|
|
|
plugins: [],
|
|
|
|
|
tools: [],
|
|
|
|
|
allowedTools: [],
|
|
|
|
|
permissionMode: 'dontAsk',
|
|
|
|
|
persistSession: false,
|
|
|
|
|
env: expect.objectContaining({ HOME: '/Users/test' }),
|
|
|
|
|
});
|
|
|
|
|
expect(probeQuery.mock.calls[0][0].options.env).not.toEqual(
|
|
|
|
|
expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('allows host-discovered context during agent loops while requiring exact KTX MCP tools and servers', async () => {
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage({
|
|
|
|
|
tools: ['mcp__ktx__load_skill'],
|
|
|
|
|
mcp_servers: [{ name: 'ktx', status: 'connected' }],
|
|
|
|
|
slash_commands: ['/help', '/compact', '/clear'],
|
|
|
|
|
skills: ['memory-agent', 'doc-reader'],
|
|
|
|
|
agents: ['claude', 'Plan', 'Explore'],
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
message: { role: 'assistant', content: [] },
|
|
|
|
|
parent_tool_use_id: null,
|
|
|
|
|
uuid: '00000000-0000-4000-8000-000000000006',
|
|
|
|
|
session_id: 'session-id',
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ subtype: 'error_max_turns', is_error: true }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runtime.runAgentLoop({
|
|
|
|
|
modelRole: 'default',
|
|
|
|
|
systemPrompt: 'system',
|
|
|
|
|
userPrompt: 'user',
|
|
|
|
|
toolSet: {
|
|
|
|
|
load_skill: {
|
|
|
|
|
name: 'load_skill',
|
|
|
|
|
description: 'Load skill.',
|
|
|
|
|
inputSchema: z.object({ name: z.string() }),
|
|
|
|
|
execute: async () => ({ markdown: 'loaded' }),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stepBudget: 1,
|
|
|
|
|
telemetryTags: { operationName: 'test' },
|
|
|
|
|
}),
|
feat(cli): profile ingest runs and split model vs tool time (#249)
* feat(cli): profile ingest runs to find where wall-clock time goes
Add opt-in profiling for `ktx ingest`. Each timed phase, work unit, and
agent loop now records durationMs / step count / token usage in the
trace, and a post-run aggregator rolls them up into a "where did the
time go" report printed to stderr.
Enable per run with KTX_PROFILE_INGEST (1/true -> human table, json ->
raw structured profile) or persistently via `ingest.profile` in
ktx.yaml. The json form emits raw milliseconds, token counts, and a
summary.headline one-line diagnosis so coding agents can parse it
directly; json wins when both env and config request profiling.
- runtime-port: RunLoopMetrics (totalMs, usage, stepCount,
stepBoundariesMs) plus onMetrics callbacks on text/object generation
- ai-sdk + claude-code runtimes: capture per-loop timing and token usage
- work-unit-executor and stages 3/4: thread metrics into trace events
- ingest-bundle.runner: time worktree / triage / clustering / index /
reconcile / squash phases and emit the profile in a finally block
(best-effort; never affects the run outcome)
- ingest-profile: new trace+transcript aggregator with table/json formatters
- config: ingest.profile flag; docs: profiling section in ktx-ingest.mdx
* fix(cli): flush tool-call logs before reading ingest profile
Tool transcripts are appended fire-and-forget so the agent hot path never
blocks on logging. The ingest profiler read them before the writes settled,
so per-work-unit toolMs (and the model-vs-tool split derived from it) could
be incomplete. Track in-flight appends and expose flushToolCallLogs() —
bounded by a timeout so it can never hang — and flush before the profiler
reads the transcript.
2026-06-01 15:49:17 +02:00
|
|
|
).resolves.toMatchObject({ stopReason: 'budget' });
|
2026-05-16 12:06:34 +02:00
|
|
|
|
|
|
|
|
const options = query.mock.calls[0][0].options;
|
|
|
|
|
expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']);
|
2026-05-18 13:38:06 +02:00
|
|
|
expect(options.managedSettings).toEqual({
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [{ serverName: 'ktx' }],
|
|
|
|
|
});
|
|
|
|
|
expect(options.strictMcpConfig).toBe(true);
|
2026-05-16 12:06:34 +02:00
|
|
|
expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({
|
|
|
|
|
behavior: 'allow',
|
|
|
|
|
toolUseID: '1',
|
|
|
|
|
});
|
|
|
|
|
expect(await options.canUseTool('Task', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: '2',
|
|
|
|
|
});
|
|
|
|
|
expect(await options.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: '3' })).toMatchObject({
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
toolUseID: '3',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still rejects unexpected tools, missing KTX tools, plugins, and non-KTX MCP servers from init messages', async () => {
|
|
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage({
|
|
|
|
|
tools: ['Bash'],
|
|
|
|
|
mcp_servers: [{ name: 'filesystem', status: 'connected' }],
|
|
|
|
|
plugins: [{ name: 'host-plugin', path: '/tmp/plugin' }],
|
|
|
|
|
}),
|
|
|
|
|
resultMessage({ result: 'hello' }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runtime.generateText({
|
|
|
|
|
role: 'default',
|
|
|
|
|
prompt: 'say hello',
|
|
|
|
|
tools: {
|
|
|
|
|
load_skill: {
|
|
|
|
|
name: 'load_skill',
|
|
|
|
|
description: 'Load skill.',
|
|
|
|
|
inputSchema: z.object({ name: z.string() }),
|
|
|
|
|
execute: async () => ({ markdown: 'loaded' }),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(
|
|
|
|
|
/Claude Code runtime isolation failed: .*tools=Bash.*missing_tools=mcp__ktx__load_skill.*mcp_servers=filesystem.*plugins=host-plugin/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes scrubbed env to object generation and agent loops', async () => {
|
|
|
|
|
const schema = z.object({ answer: z.string() });
|
|
|
|
|
const objectQuery = vi.fn((_input: any) =>
|
fix(snowflake): unblock multi-schema ingest and relationship discovery (#204)
* feat(setup): drop redundant Snowflake schema prompt; fall back to free-text on listSchemas failure
Snowflake setup previously asked for a single schema as free text, then
ran a multiselect against the discovered schemas — two schema questions
back-to-back, with the first being only a session bootstrap. The SDK's
`schema` is optional, so the bootstrap step is unnecessary.
- Remove the free-text Snowflake schema prompt; only pass `schema` to
snowflake-sdk when one is configured.
- When `listSchemas()` fails (e.g. role lacks SHOW SCHEMAS), prompt the
user for a comma-separated list, persist it as `schema_names`, and use
it as both the table-list filter and the multiselect default. Applies
to every driver with a scope-discovery spec, not just Snowflake.
- Update docs to lead with `schema_names`; keep `schema_name` as a
documented single-schema shorthand.
* fix(snowflake): keep introspecting when primary-key discovery is denied
The PK query joins INFORMATION_SCHEMA.TABLE_CONSTRAINTS and
INFORMATION_SCHEMA.KEY_COLUMN_USAGE, which require grants the
connection role may not have. Previously a 'SQL compilation error:
Object ANALYTICS.INFORMATION_SCHEMA.KEY_COLUMN_USAGE does not exist
or not authorized' aborted the entire introspect — schemas, columns,
and row counts were all discarded over a missing nice-to-have.
Wrap the constraint query in try/catch, log a one-line warning per
schema, and return an empty PK map. Columns end up with
primaryKey=false; relationship inference still has FK and profiling
to fall back on.
* fix(scan): unblock relationship discovery on Snowflake
Two adjacent bugs prevented the scan's relationship pipeline from producing
any joins on a Snowflake warehouse:
- relationship-profiling.ts fell through to a default `GROUP_CONCAT` branch
for unknown drivers. Snowflake has no GROUP_CONCAT, so every per-table
profile query failed with "Unknown function GROUP_CONCAT". Add an explicit
Snowflake branch that uses LISTAGG with a literal '\x1f' delimiter
(Snowflake requires the delimiter to be a constant, so CHR(31) is rejected).
- description-generation.ts destructured `connector.sampleTable` and
`connector.sampleColumn` into bare locals, losing the `this` binding when
the class-method connectors (Snowflake, Postgres, MySQL) were invoked.
Every sample call threw "Cannot read properties of undefined (reading
'assertConnection')" and degraded LLM descriptions to metadata-only
prompts. Call the methods through the connector instead.
Without these, even after the primary-key probe is allowed to fail softly,
the scan ends up with 0 validated relationships and an empty `joins:` block
in every shard YAML.
* test(scan): cover table-ref helpers
* feat(scan): plumb tableScope through live-database introspection port
* feat(scan): apply tableScope during metadata fetch
* feat(scan): enforce table scope at fetch boundary
* feat(scan): pool Snowflake sessions and batch enrichment for faster ingest (#206)
* feat(cli): add RSA key-pair auth option to Snowflake setup wizard
Extends the interactive Snowflake setup flow with an authentication-method
prompt (password vs RSA/JWT key-pair). The RSA branch collects a private-key
path (env/file/absolute) and an optional passphrase; the resulting connection
config records `authMethod: 'rsa'` with `privateKey` and `passphrase` instead
of `password`.
* feat(scan): pool Snowflake sessions
* fix(scan): reuse structural snapshots and cleanup connectors
* feat(scan): parallelize relationship profiling
* feat(scan): batch table description generation
* docs: document Snowflake ingest concurrency knobs
* fix(scan): close Snowflake ingest perf verification gaps
* fix(scan): keep batched description failure bounded
* feat(scan): dispatch query-history probes by connection driver
Extract historic-sql dialect resolution into a shared helper so the
status-project readiness check and the local ingest factory agree on
which connections enable query history and which probe to run. The
status command now picks the postgres/snowflake/bigquery probe based on
the connection's driver instead of always reporting against postgres,
which previously caused snowflake connections with queryHistory.enabled
to surface a misleading "driver is snowflake" failure.
Also drops a noisy console.warn from Snowflake primary-key discovery —
INFORMATION_SCHEMA.KEY_COLUMN_USAGE is commonly ungranted for read-only
roles and the FK + profiling paths handle the empty PK map already.
* fix(llm): allow StructuredOutput tool and raise maxTurns for generateObject
The Claude Code agent SDK announces an internal pseudo-tool named
StructuredOutput in the system/init message whenever outputFormat is set
to { type: 'json_schema' }. The runtime's isolation check built its
allowedToolIds set only from MCP tool ids and treated StructuredOutput
as an unexpected host-injected tool, so every generateObject call threw
"Claude Code runtime isolation failed: tools=StructuredOutput ..." and
the table-descriptions and relationship-LLM-proposal enrichment stages
recorded null output across the board.
Whitelist StructuredOutput specifically in generateObject's
allowedToolIds — the check also enforces missing_tools symmetry, so
generateText and runAgentLoop, which do not see StructuredOutput, must
not require it.
generateObject also ran with maxTurns: 1, which the model intermittently
breached when it emitted thinking text before the structured response.
Raised to 5 to give the schema-bound call enough headroom without
allowing unbounded loops. The existing tests now exercise the path with
an init message that announces StructuredOutput so the regression cannot
slip back in.
* chore(scripts): add ktx-reset.sh project-cleanup helper
Convenience script for repeatable ingest testing: takes a project
directory and prunes everything except ktx.yaml and .ktx/secrets/, so
the next ktx setup or ktx ingest run starts from a known-clean state.
2026-05-23 10:41:30 +02:00
|
|
|
stream([
|
|
|
|
|
initMessage({ tools: ['StructuredOutput'] }),
|
|
|
|
|
resultMessage({ structured_output: { answer: 'yes' } }),
|
|
|
|
|
]),
|
2026-05-16 12:06:34 +02:00
|
|
|
);
|
|
|
|
|
const objectRuntime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query: objectQuery,
|
|
|
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod', PATH: '/usr/bin' }, // pragma: allowlist secret
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(objectRuntime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({
|
|
|
|
|
answer: 'yes',
|
|
|
|
|
});
|
|
|
|
|
expect(objectQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ PATH: '/usr/bin' }));
|
2026-05-18 13:38:06 +02:00
|
|
|
expect(objectQuery.mock.calls[0][0].options.managedSettings).toEqual({
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [],
|
|
|
|
|
});
|
2026-05-16 12:06:34 +02:00
|
|
|
expect(objectQuery.mock.calls[0][0].options.env).not.toEqual(
|
|
|
|
|
expect.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod' }), // pragma: allowlist secret
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentQuery = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }),
|
|
|
|
|
{
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
message: { role: 'assistant', content: [] },
|
|
|
|
|
parent_tool_use_id: null,
|
|
|
|
|
uuid: '00000000-0000-4000-8000-000000000004',
|
|
|
|
|
session_id: 'session-id',
|
|
|
|
|
} as unknown as SDKMessage,
|
|
|
|
|
resultMessage({ subtype: 'error_max_turns', is_error: true }),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const agentRuntime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query: agentQuery,
|
|
|
|
|
env: { ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1', HOME: '/Users/test' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await agentRuntime.runAgentLoop({
|
|
|
|
|
modelRole: 'default',
|
|
|
|
|
systemPrompt: 'system',
|
|
|
|
|
userPrompt: 'user',
|
|
|
|
|
toolSet: {
|
|
|
|
|
load_skill: {
|
|
|
|
|
name: 'load_skill',
|
|
|
|
|
description: 'Load skill.',
|
|
|
|
|
inputSchema: z.object({ name: z.string() }),
|
|
|
|
|
execute: async () => ({ markdown: 'loaded' }),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stepBudget: 1,
|
|
|
|
|
telemetryTags: { operationName: 'test' },
|
|
|
|
|
});
|
|
|
|
|
expect(agentQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ HOME: '/Users/test' }));
|
2026-05-18 13:38:06 +02:00
|
|
|
expect(agentQuery.mock.calls[0][0].options.managedSettings).toEqual({
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [{ serverName: 'ktx' }],
|
|
|
|
|
});
|
2026-05-16 12:06:34 +02:00
|
|
|
expect(agentQuery.mock.calls[0][0].options.env).not.toEqual(
|
|
|
|
|
expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('maps max-turn terminal reasons to budget', () => {
|
|
|
|
|
expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_max_turns' }))).toBe('budget');
|
|
|
|
|
expect(mapClaudeCodeStopReason(resultMessage({ terminal_reason: 'max_turns' }))).toBe('budget');
|
|
|
|
|
expect(mapClaudeCodeStopReason(resultMessage({ stop_reason: 'max_turns' }))).toBe('budget');
|
|
|
|
|
expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'success', terminal_reason: 'completed' }))).toBe('natural');
|
|
|
|
|
expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_during_execution' }))).toBe('error');
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-08 15:30:35 +02:00
|
|
|
it('reports stepCount from the SDK result num_turns and mapped token usage', async () => {
|
feat(cli): profile ingest runs and split model vs tool time (#249)
* feat(cli): profile ingest runs to find where wall-clock time goes
Add opt-in profiling for `ktx ingest`. Each timed phase, work unit, and
agent loop now records durationMs / step count / token usage in the
trace, and a post-run aggregator rolls them up into a "where did the
time go" report printed to stderr.
Enable per run with KTX_PROFILE_INGEST (1/true -> human table, json ->
raw structured profile) or persistently via `ingest.profile` in
ktx.yaml. The json form emits raw milliseconds, token counts, and a
summary.headline one-line diagnosis so coding agents can parse it
directly; json wins when both env and config request profiling.
- runtime-port: RunLoopMetrics (totalMs, usage, stepCount,
stepBoundariesMs) plus onMetrics callbacks on text/object generation
- ai-sdk + claude-code runtimes: capture per-loop timing and token usage
- work-unit-executor and stages 3/4: thread metrics into trace events
- ingest-bundle.runner: time worktree / triage / clustering / index /
reconcile / squash phases and emit the profile in a finally block
(best-effort; never affects the run outcome)
- ingest-profile: new trace+transcript aggregator with table/json formatters
- config: ingest.profile flag; docs: profiling section in ktx-ingest.mdx
* fix(cli): flush tool-call logs before reading ingest profile
Tool transcripts are appended fire-and-forget so the agent hot path never
blocks on logging. The ingest profiler read them before the writes settled,
so per-work-unit toolMs (and the model-vs-tool split derived from it) could
be incomplete. Track in-flight appends and expose flushToolCallLogs() —
bounded by a timeout so it can never hang — and flush before the profiler
reads the transcript.
2026-06-01 15:49:17 +02:00
|
|
|
const query = vi.fn((_input: any) =>
|
|
|
|
|
stream([
|
|
|
|
|
initMessage(),
|
|
|
|
|
resultMessage({
|
|
|
|
|
subtype: 'success',
|
|
|
|
|
terminal_reason: 'completed',
|
2026-06-08 15:30:35 +02:00
|
|
|
num_turns: 3,
|
feat(cli): profile ingest runs and split model vs tool time (#249)
* feat(cli): profile ingest runs to find where wall-clock time goes
Add opt-in profiling for `ktx ingest`. Each timed phase, work unit, and
agent loop now records durationMs / step count / token usage in the
trace, and a post-run aggregator rolls them up into a "where did the
time go" report printed to stderr.
Enable per run with KTX_PROFILE_INGEST (1/true -> human table, json ->
raw structured profile) or persistently via `ingest.profile` in
ktx.yaml. The json form emits raw milliseconds, token counts, and a
summary.headline one-line diagnosis so coding agents can parse it
directly; json wins when both env and config request profiling.
- runtime-port: RunLoopMetrics (totalMs, usage, stepCount,
stepBoundariesMs) plus onMetrics callbacks on text/object generation
- ai-sdk + claude-code runtimes: capture per-loop timing and token usage
- work-unit-executor and stages 3/4: thread metrics into trace events
- ingest-bundle.runner: time worktree / triage / clustering / index /
reconcile / squash phases and emit the profile in a finally block
(best-effort; never affects the run outcome)
- ingest-profile: new trace+transcript aggregator with table/json formatters
- config: ingest.profile flag; docs: profiling section in ktx-ingest.mdx
* fix(cli): flush tool-call logs before reading ingest profile
Tool transcripts are appended fire-and-forget so the agent hot path never
blocks on logging. The ingest profiler read them before the writes settled,
so per-work-unit toolMs (and the model-vs-tool split derived from it) could
be incomplete. Track in-flight appends and expose flushToolCallLogs() —
bounded by a timeout so it can never hang — and flush before the profiler
reads the transcript.
2026-06-01 15:49:17 +02:00
|
|
|
usage: { input_tokens: 50, output_tokens: 10 } as never,
|
|
|
|
|
}),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
const runtime = new ClaudeCodeKtxLlmRuntime({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
modelSlots: { default: 'sonnet' },
|
|
|
|
|
query,
|
|
|
|
|
env: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runtime.runAgentLoop({
|
|
|
|
|
modelRole: 'default',
|
|
|
|
|
systemPrompt: 'system',
|
|
|
|
|
userPrompt: 'user',
|
|
|
|
|
toolSet: {},
|
|
|
|
|
stepBudget: 40,
|
|
|
|
|
telemetryTags: { operationName: 'test' },
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-08 15:30:35 +02:00
|
|
|
// Authoritative SDK count, not a re-derived per-message tally.
|
|
|
|
|
expect(result.metrics?.stepCount).toBe(3);
|
|
|
|
|
expect(result.metrics?.stepBoundariesMs).toEqual([]);
|
feat(cli): profile ingest runs and split model vs tool time (#249)
* feat(cli): profile ingest runs to find where wall-clock time goes
Add opt-in profiling for `ktx ingest`. Each timed phase, work unit, and
agent loop now records durationMs / step count / token usage in the
trace, and a post-run aggregator rolls them up into a "where did the
time go" report printed to stderr.
Enable per run with KTX_PROFILE_INGEST (1/true -> human table, json ->
raw structured profile) or persistently via `ingest.profile` in
ktx.yaml. The json form emits raw milliseconds, token counts, and a
summary.headline one-line diagnosis so coding agents can parse it
directly; json wins when both env and config request profiling.
- runtime-port: RunLoopMetrics (totalMs, usage, stepCount,
stepBoundariesMs) plus onMetrics callbacks on text/object generation
- ai-sdk + claude-code runtimes: capture per-loop timing and token usage
- work-unit-executor and stages 3/4: thread metrics into trace events
- ingest-bundle.runner: time worktree / triage / clustering / index /
reconcile / squash phases and emit the profile in a finally block
(best-effort; never affects the run outcome)
- ingest-profile: new trace+transcript aggregator with table/json formatters
- config: ingest.profile flag; docs: profiling section in ktx-ingest.mdx
* fix(cli): flush tool-call logs before reading ingest profile
Tool transcripts are appended fire-and-forget so the agent hot path never
blocks on logging. The ingest profiler read them before the writes settled,
so per-work-unit toolMs (and the model-vs-tool split derived from it) could
be incomplete. Track in-flight appends and expose flushToolCallLogs() —
bounded by a timeout so it can never hang — and flush before the profiler
reads the transcript.
2026-06-01 15:49:17 +02:00
|
|
|
expect(result.metrics?.usage).toEqual({ inputTokens: 50, outputTokens: 10, totalTokens: 60 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 12:06:34 +02:00
|
|
|
it('auth probe uses isolation options and a scrubbed env', async () => {
|
|
|
|
|
const query = vi.fn((_input: any) => stream([initMessage(), resultMessage({ result: 'ok' })]));
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: { ANTHROPIC_API_KEY: 'sk-ant-test' } }), // pragma: allowlist secret
|
|
|
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
|
expect(query.mock.calls[0][0].options).toMatchObject({
|
|
|
|
|
settingSources: [],
|
|
|
|
|
skills: [],
|
|
|
|
|
plugins: [],
|
|
|
|
|
tools: [],
|
2026-05-18 13:38:06 +02:00
|
|
|
managedSettings: {
|
|
|
|
|
allowManagedMcpServersOnly: true,
|
|
|
|
|
allowedMcpServers: [],
|
|
|
|
|
},
|
|
|
|
|
strictMcpConfig: true,
|
2026-05-16 12:06:34 +02:00
|
|
|
allowedTools: [],
|
|
|
|
|
persistSession: false,
|
|
|
|
|
env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports unsupported Claude Code models without framing them as auth failures', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
runClaudeCodeAuthProbe({
|
|
|
|
|
projectDir: '/tmp/project',
|
|
|
|
|
model: 'gpt-5',
|
|
|
|
|
query: vi.fn(),
|
|
|
|
|
env: {},
|
|
|
|
|
}),
|
|
|
|
|
).resolves.toEqual({
|
|
|
|
|
ok: false,
|
|
|
|
|
message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|