ktx/packages/cli/src/context/memory/memory-agent.service.ingest.test.ts
Andrey Avtomonov 2366b00301
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm

* refactor(workspace): rewrite @ktx/llm imports to relative paths

* refactor(workspace): fold internal packages into cli

* chore(workspace): gate dead-code with knip production mode

Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.

* refactor(cli): delete internal barrel index.ts files

The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).

This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
  (the published package entry).
- Rewrites ~270 source/test files to import each name directly from
  the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
  `create-warehouse-verification-tools.ts` (the function it defined
  locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
  the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
  live-database/extracted-schema, live-database/structural-sync,
  relationship-* feedback/review chain) plus their tests and a
  cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
  (notion-client, connector barrels in scan/local-scan-connectors
  tests) to mock the source files instead.
- Points the maintainer benchmark script
  (`scripts/relationship-benchmark-report.mjs`) at source files
  instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
  production entries only for the benchmark code reached via dist by
  the maintainer script.

Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.

`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.

* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly

Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.

Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.

* docs: align "agent clients" and "data agents" terminology

Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.

Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.

* refactor(release): single source of truth for package version

Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.

Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.

- update-public-release-version.mjs rewrites both Python pyproject.toml
  files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
  normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
  @semantic-release/git assets so the release commit back to main
  carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
  replaced with "?? getKtxCliPackageInfo().version", and
  createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
  always reflects the most recent release; no sentinel pin to
  maintain.

Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.

* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime

Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.

* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal

Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.

* fix(cli): use real package metadata in print-command-tree

The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.

* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts

Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.

Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00

433 lines
16 KiB
TypeScript

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, vi } from 'vitest';
// Module-level mock for 'ai' so generateText is a stub. This file is separate from
// memory-agent.service.spec.ts so the existing pure-helper tests don't load the mock.
vi.mock('ai', () => ({
generateText: vi.fn().mockResolvedValue({ text: '', toolCalls: [] }),
stepCountIs: (n: number) => n,
tool: (def: unknown) => def,
}));
// Imported AFTER vi.mock so the mocked module is used.
import { generateText } from 'ai';
import { SYSTEM_GIT_AUTHOR } from '../../context/tools/authors.js';
import { MemoryAgentService } from './memory-agent.service.js';
interface BuiltMocks {
appSettings: any;
prompt: any;
eventTracker: any;
telemetry: any;
skillsRegistry: any;
wikiService: any;
indexRepository: any;
knowledgeSlRefsRepository: any;
knowledgeRepository: any;
embeddingService: any;
semanticLayerService: any;
slSearchService: any;
dataSourcesService: any;
configService: any;
gitService: any;
lockingService: any;
slSourcesRepository: any;
sessionWorktreeService: any;
semanticLayerSourceReconciler: any;
agentRunner: any;
slValidator: any;
toolsetFactory: any;
logger: any;
}
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
const scopedConfig = { writeFile: vi.fn(), deleteFile: vi.fn() };
const scopedGit = { revParseHead: vi.fn().mockResolvedValue('basesha') };
const sessionWorktree = {
chatId: 'chat-1',
workdir: '/tmp/wt/session-chat-1',
branch: 'session/chat-1',
baseSha: 'basesha',
createdAt: new Date(),
git: scopedGit,
config: scopedConfig,
};
const defaults: BuiltMocks = {
appSettings: {
settings: {
ai: {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
},
llm: { memoryIngestionModel: 'test-model' },
},
},
prompt: { loadPrompt: vi.fn().mockResolvedValue('base framing') },
eventTracker: { trackEvent: vi.fn(), createTelemetryIntegration: vi.fn().mockReturnValue(undefined) },
telemetry: {
isEnabled: () => false,
appSettingsService: { settings: { telemetry: { recordInputs: false, recordOutputs: false } } },
systemConfigService: { config: { instance: { name: 'test-instance' } } },
},
skillsRegistry: {
listSkills: vi.fn().mockResolvedValue([]),
buildSkillsPrompt: vi.fn().mockReturnValue(''),
getSkill: vi.fn(),
stripFrontmatter: vi.fn(),
},
wikiService: {
forWorktree: vi.fn().mockReturnThis(),
readPage: vi.fn(),
syncSinglePage: vi.fn(),
deleteFromIndex: vi.fn(),
},
indexRepository: { listPagesForUser: vi.fn().mockResolvedValue([]) },
knowledgeSlRefsRepository: { syncFromWiki: vi.fn().mockResolvedValue({ inserted: 0, deleted: 0 }) },
knowledgeRepository: {},
embeddingService: { computeEmbedding: vi.fn() },
semanticLayerService: {
forWorktree: vi.fn().mockReturnThis(),
loadAllSources: vi.fn().mockResolvedValue({ sources: [], loadErrors: [] }),
readSourceFile: vi.fn(),
},
slSearchService: { indexSources: vi.fn(), buildSearchText: vi.fn() },
dataSourcesService: {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById: vi.fn().mockResolvedValue({
id: 'conn-1',
name: 'Warehouse',
connectionType: 'POSTGRESQL',
}),
executeQuery: vi.fn(),
},
configService: {
enqueueCommitMessageJobForExternalCommit: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn(),
deleteFile: vi.fn(),
},
gitService: {
revParseHead: vi.fn().mockResolvedValue('basesha'),
squashMergeIntoMain: vi.fn().mockResolvedValue({ ok: true, squashSha: 'cafebabe', touchedPaths: ['a.yaml'] }),
},
lockingService: {
withLock: vi.fn().mockImplementation((_key: string, fn: () => Promise<unknown>) => fn()),
},
slSourcesRepository: { deleteByConnectionAndName: vi.fn() },
sessionWorktreeService: {
create: vi.fn().mockResolvedValue(sessionWorktree),
cleanup: vi.fn().mockResolvedValue(undefined),
},
semanticLayerSourceReconciler: { upsertRow: vi.fn() },
agentRunner: { runLoop: vi.fn().mockResolvedValue({ stopReason: 'natural' }) },
slValidator: { validateSingleSource: vi.fn().mockResolvedValue({ errors: [], warnings: [] }) },
toolsetFactory: {
createIngestWuToolset: vi.fn().mockReturnValue({
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
}),
createToolset: vi.fn().mockReturnValue({
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
}),
},
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
};
return { ...defaults, ...overrides };
};
const buildService = (mocks: BuiltMocks): MemoryAgentService =>
new MemoryAgentService({
settings: {
knowledge: {
userScopedKnowledgeEnabled: mocks.appSettings.settings.ai.knowledge.userScopedKnowledgeEnabled,
},
slValidation: {
probeRowCount: mocks.appSettings.settings.ai.slValidation.probeRowCount,
},
llm: {
memoryIngestionModel: mocks.appSettings.settings.llm.memoryIngestionModel,
},
},
promptService: mocks.prompt,
skillsRegistry: mocks.skillsRegistry,
wikiService: mocks.wikiService,
knowledgeIndex: mocks.indexRepository,
knowledgeSlRefs: mocks.knowledgeSlRefsRepository,
semanticLayerService: mocks.semanticLayerService,
slSearchService: mocks.slSearchService,
connections: {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById:
mocks.dataSourcesService.getConnectionById ??
vi.fn().mockResolvedValue({
id: 'conn-1',
name: 'Warehouse',
connectionType: 'POSTGRESQL',
}),
executeQuery: mocks.dataSourcesService.executeQuery,
},
rootFileStore: mocks.configService,
gitService: mocks.gitService,
lockingService: mocks.lockingService,
slSourcesRepository: mocks.slSourcesRepository,
sessionWorktreeService: mocks.sessionWorktreeService,
semanticLayerSourceReconciler: mocks.semanticLayerSourceReconciler,
agentRunner: mocks.agentRunner,
slValidator: mocks.slValidator,
toolsetFactory: mocks.toolsetFactory,
telemetry: {
trackMemoryIngestion: mocks.eventTracker.trackEvent,
},
logger: mocks.logger,
});
const baseInput = {
userId: 'u1',
chatId: 'chat-1',
// Long enough + with a definition keyword so the prefilter doesn't skip.
userMessage: 'going forward exclude cancelled orders from revenue, this is the canonical definition',
};
const generateTextMock = vi.mocked(generateText);
beforeEach(() => {
generateTextMock.mockReset();
generateTextMock.mockResolvedValue({ text: '', toolCalls: [] } as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('MemoryAgentService.ingest — session-branch orchestration', () => {
it('happy path: creates worktree, runs LLM loop, squash-merges, enqueues note, cleans up', async () => {
const mocks = buildMocks();
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
// Phase 1: session worktree was created from main's HEAD.
expect(mocks.sessionWorktreeService.create).toHaveBeenCalledWith('chat-1', 'basesha');
// Phase 2: LLM loop ran with the assembled tools/system/prompt.
expect(mocks.agentRunner.runLoop).toHaveBeenCalledOnce();
// Phase 3: squash-merged onto main.
expect(mocks.gitService.squashMergeIntoMain).toHaveBeenCalledWith(
'session/chat-1',
SYSTEM_GIT_AUTHOR.name,
SYSTEM_GIT_AUTHOR.email,
expect.stringContaining('[chat=chat-1]'),
);
// Note enqueue happened on the ROOT configService, not the scoped one. The single
// touched path is passed as the diff scope.
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).toHaveBeenCalledWith(
{ commitHash: 'cafebabe' },
expect.stringContaining('[chat=chat-1]'),
'a.yaml',
);
// Cleanup ran with success.
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(
expect.objectContaining({ chatId: 'chat-1' }),
'success',
expect.any(Object),
);
expect(result.commitHash).toBe('cafebabe');
});
it('normalizes load_skill output to markdown while preserving structured payload', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-'));
const skillDir = join(tempDir, 'memory_agent');
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: memory_agent\n---\nSkill body', 'utf-8');
try {
const agentRunner = {
runLoop: vi.fn(async (params: any) => {
const result = await params.toolSet.load_skill.execute({ name: 'memory_agent' });
expect(result.markdown).toContain('memory_agent');
expect(result.structured).toMatchObject({ name: 'memory_agent' });
return { stopReason: 'natural' as const };
}),
};
const mocks = buildMocks({
agentRunner,
skillsRegistry: {
listSkills: vi.fn().mockResolvedValue([{ name: 'memory_agent', path: skillDir }]),
buildSkillsPrompt: vi.fn().mockReturnValue(''),
getSkill: vi.fn().mockResolvedValue({ name: 'memory_agent', path: skillDir }),
stripFrontmatter: vi.fn().mockReturnValue('Skill body'),
},
});
const svc = buildService(mocks);
await svc.ingest(baseInput);
expect(agentRunner.runLoop).toHaveBeenCalled();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => {
const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
const mocks = buildMocks();
const svc = buildService(mocks);
try {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1';
await svc.ingest(baseInput);
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system='));
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user='));
} finally {
if (previousDebugPrompts === undefined) {
delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
} else {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts;
}
}
});
it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
ok: true,
squashSha: 'basesha',
touchedPaths: [],
});
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'empty', expect.any(Object));
expect(result.commitHash).toBeNull();
});
it('conflict path: rolls back DB, cleanup(conflict, conflictPaths), returns commitHash=null with empty actions', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
ok: false,
conflict: true,
conflictPaths: ['semantic-layer/conn-x/fct_intakes.yaml'],
});
// Have the wikiService report a still-existing page in main, so rollback re-syncs.
mocks.wikiService.readPage.mockResolvedValue({
pageKey: 'phantom',
frontmatter: { summary: 'x', usage_mode: 'auto' },
content: 'body',
});
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.gitService.squashMergeIntoMain).toHaveBeenCalled();
// Cleanup got the conflict outcome + the paths.
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'conflict', {
conflictPaths: ['semantic-layer/conn-x/fct_intakes.yaml'],
});
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
expect(result.commitHash).toBeNull();
expect(result.actions).toEqual([]);
});
it('crash path: post-loop step throws → cleanup(crash), commitHash=null', async () => {
const mocks = buildMocks();
// Force the cross-ref reconciler to throw, escaping into the outer try/catch and
// landing in the crash branch.
mocks.knowledgeSlRefsRepository.syncFromWiki.mockRejectedValue(new Error('db down'));
// squashMergeIntoMain shouldn't even be reached.
mocks.gitService.squashMergeIntoMain.mockRejectedValue(new Error('should not be called after crash'));
// Need a wiki action to trigger the cross-ref code path. Easiest: have the LLM mock
// not push actions, so syncFromWiki is never called and crash won't happen here.
// Instead, force the squash to throw.
mocks.knowledgeSlRefsRepository.syncFromWiki.mockResolvedValue({ inserted: 0, deleted: 0 });
mocks.gitService.squashMergeIntoMain.mockRejectedValue(new Error('git crashed'));
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'crash', expect.any(Object));
expect(result.commitHash).toBeNull();
});
});
describe('MemoryAgentService.ingest — concurrency regression', () => {
it('two parallel ingest() calls produce distinct squash commits (no absorption)', async () => {
// FIFO lock: each acquisition chains onto the previous holder's release. This is the
// same shape as production withLock — the test asserts that two parallel ingests
// sequence both their phase-1 (worktree create) and phase-3 (squash merge) calls
// without deadlocking, and produce distinct commits.
let chain: Promise<void> = Promise.resolve();
const lockingService = {
withLock: vi.fn().mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
const previous = chain;
let releaseMe!: () => void;
chain = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await previous;
try {
return await fn();
} finally {
releaseMe();
}
}),
};
let createCount = 0;
const sessionWorktreeService = {
create: vi.fn().mockImplementation((chatId: string) => {
createCount += 1;
return Promise.resolve({
chatId,
workdir: `/tmp/wt/session-${chatId}`,
branch: `session/${chatId}`,
baseSha: 'basesha',
createdAt: new Date(),
git: { revParseHead: vi.fn().mockResolvedValue('basesha') },
config: { writeFile: vi.fn() },
});
}),
cleanup: vi.fn().mockResolvedValue(undefined),
};
let mergeCount = 0;
const gitService = {
revParseHead: vi.fn().mockResolvedValue('basesha'),
squashMergeIntoMain: vi.fn().mockImplementation(() => {
mergeCount += 1;
return Promise.resolve({
ok: true,
squashSha: `sha-${mergeCount}`,
touchedPaths: [`${mergeCount}.yaml`],
});
}),
};
const mocksA = buildMocks({ lockingService, sessionWorktreeService, gitService });
const mocksB = buildMocks({ lockingService, sessionWorktreeService, gitService });
const svcA = buildService(mocksA);
const svcB = buildService(mocksB);
const [a, b] = await Promise.all([
svcA.ingest({ ...baseInput, chatId: 'chat-A' }),
svcB.ingest({ ...baseInput, chatId: 'chat-B' }),
]);
expect(createCount).toBe(2);
expect(gitService.squashMergeIntoMain).toHaveBeenCalledTimes(2);
expect(a.commitHash).not.toBeNull();
expect(b.commitHash).not.toBeNull();
expect(a.commitHash).not.toBe(b.commitHash);
});
});