src/services/tools-index.test.ts
Rama main · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import type { AppConfig } from '../core/config/config';
import type { ToolProvider } from '../core/contracts/types';
import type { ConfigStoreContract } from '../infrastructure/config/configstore';
import type {
ToolEmbeddingRepository,
ToolEmbeddingVectorQuery,
ToolEmbeddingVectorRow,
} from './tool-index-repository';
import {
ToolIndexService,
ToolRetrievalError,
type ToolEmbedConfig,
type ToolRetrievalObservation,
} from './tools-index';
const EMBED_CONFIG: ToolEmbedConfig = {
credential: 'embed-main',
model: 'embedding-model-v1',
dim: 2,
};
const CREDENTIAL = {
name: 'embed-main',
kind: 'embedding' as const,
base_url: 'https://private-embedding.example.invalid/v1',
api_key: 'api-key-must-not-leak',
enabled: true,
};
const USER_QUERY = 'user-query-must-not-leak';
function appConfig(input: { indexLoadTimeoutMs?: number; embeddingTimeoutMs?: number } = {}): AppConfig {
return {
toolRetrieval: {
indexLoadTimeoutMs: input.indexLoadTimeoutMs ?? 100,
embeddingTimeoutMs: input.embeddingTimeoutMs ?? 100,
},
llmCredentials: {},
} as unknown as AppConfig;
}
function configStore(onGet?: (name: string) => void): ConfigStoreContract {
return {
credentials: {
async get(name: string) {
onGet?.(name);
return name === CREDENTIAL.name ? CREDENTIAL : null;
},
async touch() { /* observation-only field */ },
},
} as unknown as ConfigStoreContract;
}
function repository(
listVectors: (provider: string, query: ToolEmbeddingVectorQuery) => Promise<ToolEmbeddingVectorRow[]>,
): ToolEmbeddingRepository {
return {
async listSnapshot() { return []; },
async deleteProvider() { /* test double */ },
async upsert() { /* test double */ },
async deleteTools() { /* test double */ },
listVectors,
};
}
function vectorBuffer(values: number[]): Buffer {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function vectorRow(name: string, values: number[], scope = 'order.read'): ToolEmbeddingVectorRow {
return {
tool_name: name,
scope,
embedding: vectorBuffer(values),
model: EMBED_CONFIG.model,
dim: EMBED_CONFIG.dim,
};
}
function embeddingResponse(values: number[]): Response {
return new Response(JSON.stringify({ data: [{ index: 0, embedding: values }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function indexedProvider(): ToolProvider {
return {
name: 'orders',
enabled: true,
base_url: 'https://orders.example.invalid',
secret: 'secret',
spec_source: 'inline',
spec_json: JSON.stringify({
openapi: '3.0.0',
info: { title: 'orders', version: '1' },
paths: {
'/orders': {
get: {
operationId: 'order_list',
summary: '查询订单',
'x-agent-capability': { version: 1, enabled: true, scope: 'order.read' },
},
},
},
}),
log_payload: false,
timeout_ms: 10_000,
rate_limit_per_min: 60,
} as ToolProvider;
}
function useFetch(
t: TestContext,
stub: (...args: Parameters<typeof fetch>) => Promise<Response>,
): void {
const original = globalThis.fetch;
globalThis.fetch = stub as typeof fetch;
t.after(() => { globalThis.fetch = original; });
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function assertRetrievalError(reason: ToolRetrievalError['reason']): (error: unknown) => true {
return (error: unknown) => {
assert.ok(error instanceof ToolRetrievalError);
assert.equal(error.reason, reason);
return true;
};
}
test('tool index retrieves ranked hits, reports one sanitized observation, and reuses a fresh cache', async (t) => {
const queries: Array<{ provider: string; query: ToolEmbeddingVectorQuery }> = [];
let credentialGets = 0;
let fetchCalls = 0;
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(() => { credentialGets++; }),
appConfig({ indexLoadTimeoutMs: 321, embeddingTimeoutMs: 654 }),
repository(async (provider, query) => {
queries.push({ provider, query });
return [vectorRow('order_list', [1, 0]), vectorRow('shipment_list', [0, 1], 'shipment.read')];
}),
);
useFetch(t, async (_input, init) => {
fetchCalls++;
const body = JSON.parse(String(init?.body)) as { model: string; dimensions: number; input: string[] };
assert.equal(body.model, EMBED_CONFIG.model);
assert.equal(body.dimensions, EMBED_CONFIG.dim);
assert.deepEqual(body.input, [USER_QUERY]);
assert.ok(init?.signal);
return embeddingResponse([4, 1]);
});
const first = await service.retrieve(
'orders',
new Set(['order_list', 'shipment_list']),
USER_QUERY,
EMBED_CONFIG,
{ minScore: 0, maxTools: 2, observe: (observation) => { observations.push(observation); } },
);
const second = await service.retrieve(
'orders',
new Set(['order_list', 'shipment_list']),
USER_QUERY,
EMBED_CONFIG,
{ minScore: 0, maxTools: 1, observe: (observation) => { observations.push(observation); } },
);
assert.deepEqual(first?.map((hit) => hit.name), ['order_list', 'shipment_list']);
assert.deepEqual(second?.map((hit) => hit.name), ['order_list']);
assert.deepEqual(queries, [{
provider: 'orders',
query: { model: EMBED_CONFIG.model, dim: EMBED_CONFIG.dim, timeoutMs: 321 },
}]);
assert.equal(credentialGets, 2);
assert.equal(fetchCalls, 2);
assert.equal(observations.length, 2);
assert.deepEqual(observations.map((item) => item.cache_state), ['miss', 'fresh']);
assert.deepEqual(observations.map((item) => item.status), ['ok', 'ok']);
assert.deepEqual(observations.map((item) => item.picked), [2, 1]);
const serialized = JSON.stringify(observations);
assert.doesNotMatch(serialized, /user-query-must-not-leak|private-embedding|api-key-must-not-leak/);
});
test('a cold index timeout is classified once and never reaches credential resolution or fetch', async (t) => {
let credentialGets = 0;
let fetchCalls = 0;
const queries: ToolEmbeddingVectorQuery[] = [];
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(() => { credentialGets++; }),
appConfig({ indexLoadTimeoutMs: 20 }),
repository(async (_provider, query) => {
queries.push(query);
return await new Promise<ToolEmbeddingVectorRow[]>(() => undefined);
}),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
const startedAt = Date.now();
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
assertRetrievalError('index_load_timeout'),
);
assert.ok(Date.now() - startedAt < 500, 'cold index wait should remain bounded');
assert.deepEqual(queries, [{ model: EMBED_CONFIG.model, dim: EMBED_CONFIG.dim, timeoutMs: 20 }]);
assert.equal(credentialGets, 0);
assert.equal(fetchCalls, 0);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.status, 'degraded');
assert.equal(observations[0]?.reason, 'index_load_timeout');
});
test('concurrent cold retrievals share one in-flight index load', async (t) => {
const pendingRows = deferred<ToolEmbeddingVectorRow[]>();
let listCalls = 0;
let fetchCalls = 0;
const service = new ToolIndexService(
configStore(),
appConfig(),
repository(async () => {
listCalls++;
return await pendingRows.promise;
}),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
const first = service.retrieve('orders', new Set(['order_list']), 'first query', EMBED_CONFIG, { minScore: 0, maxTools: 1 });
const second = service.retrieve('orders', new Set(['order_list']), 'second query', EMBED_CONFIG, { minScore: 0, maxTools: 1 });
assert.equal(listCalls, 1);
pendingRows.resolve([vectorRow('order_list', [1, 0])]);
const [firstHits, secondHits] = await Promise.all([first, second]);
assert.deepEqual(firstHits?.map((hit) => hit.name), ['order_list']);
assert.deepEqual(secondHits?.map((hit) => hit.name), ['order_list']);
assert.equal(listCalls, 1);
assert.equal(fetchCalls, 2);
});
test('repository rejection is classified as a sanitized index error and observed once', async (t) => {
let credentialGets = 0;
let fetchCalls = 0;
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(() => { credentialGets++; }),
appConfig(),
repository(async () => { throw new Error('db password=must-not-leak'); }),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
(error) => {
assertRetrievalError('index_load_error')(error);
assert.doesNotMatch((error as Error).message, /password|must-not-leak/);
return true;
},
);
assert.equal(credentialGets, 0);
assert.equal(fetchCalls, 0);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.reason, 'index_load_error');
assert.doesNotMatch(JSON.stringify(observations), /user-query-must-not-leak|password|must-not-leak/);
});
test('a stalled embedding request is aborted and classified as embedding_timeout', async (t) => {
let aborted = false;
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(),
appConfig({ embeddingTimeoutMs: 20 }),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async (_input, init) => {
const signal = init?.signal;
assert.ok(signal);
return await new Promise<Response>((_resolve, reject) => {
const onAbort = (): void => {
aborted = true;
reject(new DOMException('request aborted', 'AbortError'));
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
});
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
assertRetrievalError('embedding_timeout'),
);
assert.equal(aborted, true);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.reason, 'embedding_timeout');
});
test('embedding HTTP failure is classified as embedding_error', async (t) => {
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(),
appConfig(),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async () => new Response('upstream-body-must-not-leak', { status: 503 }));
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
assertRetrievalError('embedding_error'),
);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.reason, 'embedding_error');
assert.doesNotMatch(JSON.stringify(observations), /upstream-body|user-query-must-not-leak|private-embedding|api-key/);
});
test('invalid embedding response is classified as embedding_error', async (t) => {
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(),
appConfig(),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
assertRetrievalError('embedding_error'),
);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.reason, 'embedding_error');
});
test('observation callback failures never block a successful retrieval', async (t) => {
const service = new ToolIndexService(
configStore(),
appConfig(),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async () => embeddingResponse([1, 0]));
const syncFailure = await service.retrieve('orders', new Set(['order_list']), 'sync observer', EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: () => { throw new Error('sync observer failed'); },
});
const asyncFailure = await service.retrieve('orders', new Set(['order_list']), 'async observer', EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: async () => { throw new Error('async observer failed'); },
});
assert.deepEqual(syncFailure?.map((hit) => hit.name), ['order_list']);
assert.deepEqual(asyncFailure?.map((hit) => hit.name), ['order_list']);
});
test('a hanging observation sink never delays retrieval completion', async (t) => {
const service = new ToolIndexService(
configStore(),
appConfig(),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async () => embeddingResponse([1, 0]));
const hits = await Promise.race([
service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: async () => await new Promise<void>(() => undefined),
}),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('observation blocked retrieval')), 100)),
]);
assert.deepEqual(hits?.map((hit) => hit.name), ['order_list']);
});
test('a stalled credential lookup is bounded before query embedding starts', async (t) => {
let fetchCalls = 0;
const observations: ToolRetrievalObservation[] = [];
const store = {
credentials: {
async get() { return await new Promise<never>(() => undefined); },
async touch() { /* observation-only field */ },
},
} as unknown as ConfigStoreContract;
const service = new ToolIndexService(
store,
appConfig({ indexLoadTimeoutMs: 20 }),
repository(async () => [vectorRow('order_list', [1, 0])]),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
const startedAt = Date.now();
await assert.rejects(
() => service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
}),
assertRetrievalError('credential_timeout'),
);
assert.ok(Date.now() - startedAt < 500, 'credential lookup wait should remain bounded');
assert.equal(fetchCalls, 0);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.reason, 'credential_timeout');
});
test('stale cache serves immediately while one background refresh replaces it', async (t) => {
const refreshRows = deferred<ToolEmbeddingVectorRow[]>();
const observations: ToolRetrievalObservation[] = [];
const queries: ToolEmbeddingVectorQuery[] = [];
let listCalls = 0;
let fetchCalls = 0;
const service = new ToolIndexService(
configStore(),
appConfig({ indexLoadTimeoutMs: 400 }),
repository(async (_provider, query) => {
queries.push(query);
listCalls++;
if (listCalls === 1) return [vectorRow('old_tool', [1, 0])];
return await refreshRows.promise;
}),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse(fetchCalls <= 2 ? [1, 0] : [0, 1]);
});
const first = await service.retrieve('orders', new Set(['old_tool', 'new_tool']), 'prime cache', EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
});
assert.deepEqual(first?.map((hit) => hit.name), ['old_tool']);
const cache = (service as unknown as { cache: Map<string, { at: number; rows: unknown[] }> }).cache;
assert.equal(cache.size, 1);
for (const entry of cache.values()) entry.at = Date.now() - 11 * 60_000;
const staleResult = service.retrieve('orders', new Set(['old_tool', 'new_tool']), 'serve stale', EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
});
let staleDeadline: NodeJS.Timeout | undefined;
const stale = await Promise.race([
staleResult,
new Promise<never>((_resolve, reject) => {
staleDeadline = setTimeout(() => reject(new Error('stale cache blocked on refresh')), 250);
}),
]).finally(() => { if (staleDeadline) clearTimeout(staleDeadline); });
assert.deepEqual(stale?.map((hit) => hit.name), ['old_tool']);
assert.equal(listCalls, 2);
assert.equal(observations.length, 1);
assert.equal(observations[0]?.cache_state, 'stale');
refreshRows.resolve([vectorRow('new_tool', [0, 1])]);
await new Promise<void>((resolve) => setImmediate(resolve));
const fresh = await service.retrieve('orders', new Set(['old_tool', 'new_tool']), 'use refresh', EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
});
assert.deepEqual(fresh?.map((hit) => hit.name), ['new_tool']);
assert.equal(listCalls, 2);
assert.equal(observations[1]?.cache_state, 'fresh');
assert.deepEqual(queries, [
{ model: EMBED_CONFIG.model, dim: EMBED_CONFIG.dim, timeoutMs: 400 },
{ model: EMBED_CONFIG.model, dim: EMBED_CONFIG.dim, timeoutMs: 400 },
]);
});
test('invalidate prevents an older background refresh from repopulating the cache', async (t) => {
const oldRefresh = deferred<ToolEmbeddingVectorRow[]>();
let listCalls = 0;
const service = new ToolIndexService(
configStore(),
appConfig({ indexLoadTimeoutMs: 400 }),
repository(async () => {
listCalls++;
if (listCalls === 1) return [vectorRow('old_tool', [1, 0])];
if (listCalls === 2) return await oldRefresh.promise;
return [vectorRow('new_tool', [1, 0])];
}),
);
useFetch(t, async () => embeddingResponse([1, 0]));
const allowed = new Set(['old_tool', 'new_tool']);
assert.deepEqual((await service.retrieve('orders', allowed, 'prime', EMBED_CONFIG, { minScore: 0, maxTools: 1 }))?.map((hit) => hit.name), ['old_tool']);
const cache = (service as unknown as { cache: Map<string, { at: number; rows: unknown[] }> }).cache;
for (const entry of cache.values()) entry.at = Date.now() - 11 * 60_000;
assert.deepEqual((await service.retrieve('orders', allowed, 'stale', EMBED_CONFIG, { minScore: 0, maxTools: 1 }))?.map((hit) => hit.name), ['old_tool']);
assert.equal(listCalls, 2);
service.invalidate('orders');
assert.deepEqual((await service.retrieve('orders', allowed, 'after reindex', EMBED_CONFIG, { minScore: 0, maxTools: 1 }))?.map((hit) => hit.name), ['new_tool']);
assert.equal(listCalls, 3);
oldRefresh.resolve([vectorRow('late_old_tool', [1, 0])]);
await new Promise<void>((resolve) => setImmediate(resolve));
const final = await service.retrieve('orders', new Set(['new_tool', 'late_old_tool']), 'final', EMBED_CONFIG, { minScore: 0, maxTools: 1 });
assert.deepEqual(final?.map((hit) => hit.name), ['new_tool']);
assert.equal(listCalls, 3);
});
test('prewarm loads stored vectors only and the first retrieval reuses the fresh cache', async (t) => {
let listCalls = 0;
let credentialGets = 0;
let fetchCalls = 0;
const observations: ToolRetrievalObservation[] = [];
const service = new ToolIndexService(
configStore(() => { credentialGets++; }),
appConfig(),
repository(async () => {
listCalls++;
return [vectorRow('order_list', [1, 0])];
}),
);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
assert.equal(await service.prewarmProvider('orders', EMBED_CONFIG), 1);
assert.equal(listCalls, 1);
assert.equal(credentialGets, 0, 'prewarm must not resolve the embedding credential');
assert.equal(fetchCalls, 0, 'prewarm must not call the embedding API');
const hits = await service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
});
assert.deepEqual(hits?.map((hit) => hit.name), ['order_list']);
assert.equal(listCalls, 1, 'first retrieval should reuse the prewarmed in-memory index');
assert.equal(credentialGets, 1);
assert.equal(fetchCalls, 1);
assert.equal(observations[0]?.cache_state, 'fresh');
});
test('legacy repositories without row-level coordinates degrade without consulting a mutable snapshot', async (t) => {
let snapshotCalls = 0;
let fetchCalls = 0;
const observations: ToolRetrievalObservation[] = [];
const repo: ToolEmbeddingRepository = {
async listSnapshot(_provider, query) {
snapshotCalls++;
assert.equal(query?.timeoutMs, 100);
return [{ tool_name: 'order_list', text_hash: 'new-hash', model: EMBED_CONFIG.model, dim: EMBED_CONFIG.dim }];
},
async deleteProvider() { /* legacy adapter */ },
async upsert() { /* legacy adapter */ },
async deleteTools() { /* legacy adapter */ },
async listVectors() {
return [{ tool_name: 'order_list', scope: 'order.read', embedding: vectorBuffer([1, 0]) }];
},
};
const service = new ToolIndexService(configStore(), appConfig(), repo);
useFetch(t, async () => {
fetchCalls++;
return embeddingResponse([1, 0]);
});
const hits = await service.retrieve('orders', new Set(['order_list']), USER_QUERY, EMBED_CONFIG, {
minScore: 0,
maxTools: 1,
observe: (observation) => { observations.push(observation); },
});
assert.equal(hits, null);
assert.equal(snapshotCalls, 0, 'a second mutable read cannot prove the coordinates of legacy vector rows');
assert.equal(fetchCalls, 0, 'missing row-level coordinates must fail before query embedding');
assert.equal(observations[0]?.reason, 'index_empty');
});
test('same-provider rebuilds serialize the full snapshot-to-persist critical section', async (t) => {
const firstPersist = deferred<void>();
let credentialGets = 0;
let snapshots = 0;
let upserts = 0;
let active = 0;
let maxActive = 0;
const repo: ToolEmbeddingRepository = {
async listSnapshot() {
snapshots++;
active++;
maxActive = Math.max(maxActive, active);
return [];
},
async deleteProvider() { /* test double */ },
async upsert() {
upserts++;
if (upserts === 1) await firstPersist.promise;
active--;
},
async deleteTools() { /* test double */ },
async listVectors() { return []; },
};
const service = new ToolIndexService(configStore(() => { credentialGets++; }), appConfig(), repo);
useFetch(t, async () => embeddingResponse([1, 0]));
const first = service.reindexProvider(indexedProvider(), EMBED_CONFIG);
const second = service.reindexProvider(indexedProvider(), EMBED_CONFIG);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(credentialGets, 1, 'second rebuild must not resolve credentials before the first persists');
assert.equal(snapshots, 1, 'second rebuild must not read a snapshot before the first persists');
assert.equal(upserts, 1);
assert.equal(maxActive, 1);
firstPersist.resolve();
await Promise.all([first, second]);
assert.equal(credentialGets, 2);
assert.equal(snapshots, 2);
assert.equal(upserts, 2);
assert.equal(maxActive, 1, 'same-provider rebuild critical sections must never overlap');
});
test('a queued rebuild rereads the authoritative provider and never writes a stale spec or coordinate', async (t) => {
const latest = {
...indexedProvider(),
spec_json: JSON.stringify({
openapi: '3.0.0',
info: { title: 'orders', version: '2' },
paths: {
'/orders/latest': {
get: {
operationId: 'order_list_latest',
summary: '查询最新订单',
'x-agent-capability': { version: 1, enabled: true, scope: 'order.read' },
},
},
},
}),
embed_credential: EMBED_CONFIG.credential,
embed_model: 'embedding-model-v2',
embed_dim: 3,
} satisfies ToolProvider;
const written: Array<{ tool_name: string; model: string; dim: number }> = [];
const store = {
toolProviders: { async get() { return latest; } },
credentials: {
async get() { return CREDENTIAL; },
async touch() { /* observation-only field */ },
},
} as unknown as ConfigStoreContract;
const repo: ToolEmbeddingRepository = {
async listSnapshot() { return []; },
async deleteProvider() { /* test double */ },
async upsert(row) { written.push({ tool_name: row.tool_name, model: row.model, dim: row.dim }); },
async deleteTools() { /* test double */ },
async listVectors() { return []; },
};
const service = new ToolIndexService(store, appConfig(), repo);
useFetch(t, async (_input, init) => {
const body = JSON.parse(String(init?.body)) as { model: string; dimensions: number };
assert.equal(body.model, 'embedding-model-v2');
assert.equal(body.dimensions, 3);
return embeddingResponse([1, 0, 0]);
});
await service.reindexProvider(indexedProvider(), EMBED_CONFIG);
assert.deepEqual(written, [{ tool_name: 'order_list_latest', model: 'embedding-model-v2', dim: 3 }]);
});
test('coordinate migration keeps the previous persisted index when embedding fails before cutover', async (t) => {
let deletes = 0;
let upserts = 0;
const repo: ToolEmbeddingRepository = {
async listSnapshot() {
return [{ tool_name: 'order_list', text_hash: 'old-hash', model: 'old-model', dim: 2 }];
},
async deleteProvider() { deletes++; },
async upsert() { upserts++; },
async deleteTools() { /* test double */ },
async listVectors() { return []; },
};
const service = new ToolIndexService(configStore(), appConfig(), repo);
useFetch(t, async () => new Response('upstream failed', { status: 503 }));
await assert.rejects(() => service.reindexProvider(indexedProvider(), EMBED_CONFIG));
assert.equal(deletes, 0, 'old coordinate must remain until every replacement vector has been computed');
assert.equal(upserts, 0);
});
test('coordinate migration refuses non-atomic legacy storage without deleting the old index', async (t) => {
let deletes = 0;
let upserts = 0;
const repo: ToolEmbeddingRepository = {
async listSnapshot() {
return [{ tool_name: 'order_list', text_hash: 'old-hash', model: 'old-model', dim: 2 }];
},
async deleteProvider() { deletes++; },
async upsert() { upserts++; },
async deleteTools() { /* test double */ },
async listVectors() { return []; },
};
const service = new ToolIndexService(configStore(), appConfig(), repo);
useFetch(t, async () => embeddingResponse([1, 0]));
await assert.rejects(
() => service.reindexProvider(indexedProvider(), EMBED_CONFIG),
/不支持原子坐标系切换/,
);
assert.equal(deletes, 0);
assert.equal(upserts, 0);
});