mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(embeddings): retry unparseable 200 responses and survive partial embedding failures (#2790) A long-running embedding job against an OpenAI-compatible endpoint could lose hours of work to a single transient glitch, then refuse to recover on the next run. Four defects compounded: 1. An HTTP 200 carrying a truncated or non-JSON body was never retried. `classifyOutcome` treats any 2xx as success, and the `resp.json()` parse ran after `resilientFetch` had already returned, so the parse failure surfaced as a terminal error. Measured: a 503 got 3 attempts, a garbage 200 got 1. The parse and the response-shape check now run inside the `fetchImpl` callback, so a bad body is classified as a retryable failure and gets the same backoff as a 5xx. This also stops a garbage 200 from calling the circuit breaker's `recordSuccess()`, which previously erased accumulated failures and meant an endpoint alternating 5xx and garbage-200 could never trip it. 2. One failed `embedBatch` sub-batch aborted the entire pipeline. Failures are now tolerated: the sub-batch's node ids are collected and all of their embedding rows are deleted, so those nodes hold zero rows and are re-embedded later. Deleting rather than keeping partial rows is deliberate — chunk arrays are flat over a 16-node batch and sliced by 8, so a node's chunks can straddle a sub-batch boundary, and surviving rows carry the current content hash. The hash maps collapse per-chunk rows last-row-wins, so a partially embedded node would read as fresh forever and never regenerate its missing chunks. A run that fails 5 sub-batches in a row still aborts, and rethrows the first error of the streak rather than the last: after 3 failures the circuit breaker opens, so later errors degrade into "circuit open, retry in 30s" while the first still names the real defect. 3. The Phase 5 `embeddingCount === 0` fail-fast could not tell "wrote nothing" from "could not ask" — the count query's catch was silent. The count is now tri-state and only a known zero after real work is fatal. A non-numeric count previously bypassed the gate entirely, because `Number()` returns NaN and `NaN === 0` is false, and then serialized as `embeddings: null`. An unverified count no longer certifies `capabilities.vectorSearch.status`. 4. `saveEmbeddingCheckpoint` wrote a completion-shaped meta: it advanced `lastCommit`, wrote the new `fileHashes` and cleared `incrementalInProgress`. The first checkpoint window fires before a single embedding exists, and on a full rebuild the graph is still in a staging database that a crash discards. The next run then diffed against the advanced hashes, saw no changes and preserved the old graph — the "skipping wipe" symptom in the report. It now re-reads meta and replaces only the checkpoint, matching what the server endpoint already did. A partially failed run keeps its checkpoint with the failed ids in `pendingNodeIds`, so the next plain `analyze` regenerates them through the existing resume path. Clearing it would have been silent data loss: a plain run derives `shouldGenerateEmbeddings: false` once embeddings exist, so the pipeline would never have run again. The old crash-and-abort self-healed only by accident, via the checkpoint its crash left behind. `gitnexus status` reports the index incomplete until the nodes recover, and `--drop-embeddings` still abandons them. `POST /api/embed` is the pipeline's other caller and was discarding the result, reporting "Embeddings complete" for a partial run. It now persists the pending ids and reports the run as failed with the underlying endpoint error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(embeddings): abort a run whose sub-batch failure ratio is too high (#2790) The consecutive-failure ceiling only catches a total outage, because any successful sub-batch resets it. An endpoint under load shedding that alternates success and failure never trips it, so the run walks the whole corpus, deletes every failed node's rows and exits 0 having dropped a large fraction of the index. The retained checkpoint made that visible in `gitnexus status`, but a run that drops a quarter of the corpus should tell the operator to fix their endpoint, not leave them to notice a status flag. Adds a cumulative guard: abort once more than 25% of attempted sub-batches have failed, evaluated as the run progresses and gated behind a floor of 20 attempted sub-batches. The shape follows Resilience4j's circuit breaker (failure rate plus a minimum-sample floor) because it is the only one of the surveyed designs that answers the small-repo case — a three node repo can fail one sub-batch and never accumulate enough sample for a ratio to mean anything. The rate sits below a live traffic breaker's 50% because a batch indexer's job is to index the whole corpus rather than serve degraded traffic, and above Hadoop's single-digit `failures.maxpercent` because tolerating transient hiccups is the point of the change this follows. The guard reuses the existing break-then-cleanup path, so the failed batch's DELETE still runs before the rethrow, and it wraps the retained first-error-of- streak rather than inventing a new one, so the message names both the ratio and the underlying endpoint failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): record the embedding count after /api/embed so the next analyze cannot wipe it `POST /api/embed` generated embeddings and wrote them to the database but never wrote `stats.embeddings` into meta.json. Its checkpoint writer replaced only `embeddingCheckpoint`, and the finalize write folded in nothing else. So a repo embedded purely through the server kept whatever count the last CLI `analyze` stamped, which is 0 for a repo analyzed without embeddings. The next CLI run read `existingEmbeddingCount = 0`, `deriveEmbeddingMode` returned `shouldLoadCache: false`, and `gitnexus analyze --force` wiped the database with no cache load. Every server generated embedding was silently destroyed, with no warning — the user just lost semantic search. The route now measures the live count with the same query the CLI uses and folds it into both meta writes. The measurement is tri-state and deliberately never falls back to 0: an unverified count is written as absent rather than as zero, because a wrong-low value is exactly what arms the wipe. It is taken after `flushWAL()` and inside `withLbugDb`, so it describes durable rows and the connection is still open. A partial run records its honest count too, alongside the retained checkpoint, so the next CLI run preserves the partial index instead of discarding it. Found while working #2790; not part of that issue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(embeddings): retry short 200 bodies and stop laundering body-phase timeouts Two gaps in the #2790 retry fix, both found by review. A 200 carrying `{"data": []}` or fewer vectors than inputs passed the in-`fetchImpl` shape check, because `every(isEmbeddingItem)` is vacuously true for an empty array. `resilientFetch` then classified it `success` and called `recordSuccess()`, erasing the outage signal, and the cardinality check in `httpEmbed` threw terminally one attempt later. That is exactly the pair of properties #2790 was filed about, still broken for this body shape — and worse than before the fix, since the pipeline now tolerates the error by deleting those nodes' rows instead of aborting loudly. The count check moves inside the retried callback; the outer one stays as a backstop. The `.json()` catch also swallowed every rejection, not just parse errors. `AbortSignal.any([caller, timeout])` is wired to the body stream, so a stalled body rejects with a DOMException — which, wrapped in a plain Error, defeated `classifyOutcome`'s terminal-network test. Measured: the same TimeoutError got 3 attempts and "unparseable response" when raised during the body read, but 1 attempt and "timed out after 180000ms" when raised by fetch itself, and three such sub-batches opened the process-global breaker that `recordNeutral()` exists to protect. Abort-like DOMExceptions are now re-raised unchanged. The dimension check stays outside the loop deliberately: it validates against `config.dimensions ?? DEFAULT_DIMS`, not the request-dimensions argument, and a width mismatch is a configuration error where retrying only triples latency and books failures against a healthy endpoint. Adds the negative assertion the review found missing: response body text must never reach the user-facing error string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(embeddings): scale the sub-batch failure-ratio floor to the run The cumulative guard needed 20 attempted sub-batches before a failure rate could abort anything — roughly 160 chunks, or ~80 embeddable nodes at the default subBatchSize of 8. A 50-node repo whose endpoint sheds every other sub-batch fails half of them and still exits 0: the ratio guard is below its floor, and every intervening success resets the consecutive ceiling. The floor was a good choice for a first run over a small repo, where one failure out of one sub-batch is 100% and means nothing. The defect is that every resume run has that shape by construction — its node set is only the pending ids — so the guard was structurally off in the one run whose entire purpose is retrying against the endpoint that already failed. The floor is now sized to the run: clamp(ceil(totalNodes / 16), 5, 20). The lower bound keeps the case the flat floor protected; the upper bound preserves today's behavior above 320 nodes and avoids a proportional-only floor perversely weakening the guard at scale, where a sixteenth of a 20k-node repo would be 1250 sub-batches of damage before a rate could fire. Resilience4j can use a constant minimumNumberOfCalls because a breaker sits on an unbounded call stream; a batch indexer has a finite budget, so a constant can exceed the whole run. The ratio is still evaluated only inside the catch. That is already its local maximum — both counters have just incremented — so sampling more often would only ever observe lower ratios. Also: a failing cleanup DELETE no longer swallows the abort, which was discarding the retained first-error-of-the-streak that names the real endpoint fault; `ceilingError` is renamed `abortError` since it carries the ratio abort too; and three `{ error }` log keys become `{ err }` (#2114 — an arbitrary key serializes to `{}`, losing message and stack). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(analyze): one tri-state embedding counter, and stop partial runs wedging later runs The tri-state count doctrine this branch introduced was applied at two of its three CLI sites, and the two implementations that were meant to mirror each other had already drifted. `measurePersistedEmbeddingCount` moves to `core/embedding-count.ts` — beside `embedding-mode.ts`, with the same no-native-imports property, and outside `core/embeddings/` so the lazy-embeddings convention (#2370) still holds. All three call sites now share it. - The mid-run `onCheckpoint` counter ran the query bare. A throw there — DB busy, connection closed, read-only, the VECTOR DML lock (#2623) — rejected the callback out of `runEmbeddingPipeline` and killed the analyze before Phase 5 could apply the tri-state that exists for exactly this case. A non-numeric cell wrote `stats.embeddings: null` to disk mid-run. - Phase 5 used `?? 0` while the server used `?? Number.NaN`, under a comment asserting both measured the field the same way. `Number.isFinite(0)` is true, so a no-row answer became a *measured* zero and hard-failed a run whose embeddings had all persisted. - The unknown-count fallback read `existingMeta`, assigned once at run start, so it republished the pre-run figure over the fresher count the terminal checkpoint had already written. With a prior count of 0 that armed the wipe chain: hasExisting false, shouldLoadCache false, and the next --force discards live embeddings. It now re-reads the latest on-disk meta, and an unverifiable count retains a recovery marker instead of clearing it. A completed-but-partial run also planted a landmine. Its checkpoint is stamped with the run's embedding identity, so a later plain `gitnexus analyze` from a hook, a CI job, or a shell without GITNEXUS_EMBEDDING_URL resolved provider 'local' and threw before any phase ran — after an exit-0 run, where previously only a visible crash left that state. `--force` did not help: the resume gate inspected only `--drop-embeddings`. `RepoMeta.embeddingCheckpoint` gains `kind` to tell the two situations apart. An 'interrupted' marker (or one with no kind, so markers already on disk keep the stricter path) still fails closed — its nodes may be half-written, and resuming under a foreign model would mix vector spaces. A 'partial' marker names nodes the pipeline already deleted to zero rows, so nothing is at risk: an identity mismatch drops the pending set with a warning and continues. `--force` now discards a checkpoint, and `attempts` bounds the retry at EMBEDDING_RESUME_MAX_ATTEMPTS (3, matching the HTTP embedder's and the WAL driver's existing per-operation budgets) so a node the endpoint deterministically rejects converges instead of keeping the repo incomplete forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): close the SSE stream on terminal job status, not a progress phase A tolerated partial run reached SSE clients as a clean success — a regression in this branch's own claim that /api/embed reports a partial run as failed. The pipeline emits `phase:'ready'` unconditionally before returning, including when it dropped nodes. The route mapped that to `'complete'`, and `mountSSEProgress` treated a terminal-looking *progress phase* as terminal: write the event, `res.end()`, `unsubscribe()`. The route's own `updateJob({status:'failed'})` then fired into a stream with no listener, and the web app had already shown "ready". Before this branch the pipeline threw, which produced `phase:'error'` and did reach the client. Pollers on GET /api/embed/:jobId were unaffected, so the two consumers disagreed. Terminality is a property of the job, so the relay now asks the job. Remapping `ready` alone would have left the trap armed: the `error -> 'failed'` mapping has the identical shape and would emit `event: failed` with `error: undefined` before the catch block fills the message in. `ready` is additionally remapped to `finalizing` so a poller no longer sees `status:'analyzing'` next to `progress.phase:'complete'`. The single-terminal-event property (#2264) is preserved on both the clean and partial paths, and /api/analyze is unaffected — its terminal progress phase is 'done', never 'complete'. `AnalyzeJob` gains an optional `partial` payload so a client can tell a partial run from a total failure without a new status member; it is absent on every other job, so existing payloads stay byte-identical. Consuming it in gitnexus-web is left to that app's owner — today it renders both as the same red retry chip. `resolveEmbedRunOutcome` moves to `embed-run-outcome.ts` and `mountSSEProgress` to `sse-progress.ts`, both free of Express/LadybugDB/MCP imports, and the local count copy is replaced by the shared `core/embedding-count.ts`. Reaching three pure functions previously meant importing the whole server: measured at ~20s against a 30s test timeout, with one observed timeout failure. That file is now 1.6s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document the partial embedding index and its recovery A run can now finish exit 0 with a partial embedding index, which neither operator doc described. GUARDRAILS' "Embeddings vanished after analyze" Sign keys its trigger on `stats.embeddings` being 0 and lists "the only ways to end up at zero". A partial run stamps an honest non-zero count and sets `embeddingCheckpoint`, so the operator's actual symptom is `incompleteReasons: ["embedding-checkpoint-pending"]` — a state that Sign cannot match. Adds a Sign for it and drops the exhaustive framing from the existing one. RUNBOOK gains the recovery path: a plain `gitnexus analyze` is correct and needs no flag, because a retained checkpoint forces generation for the pending nodes regardless of flags. Also corrects two stale claims — that `stats.embeddings` is always freshly measured (it can carry forward when the count query cannot answer, which is why `capabilities.vectorSearch.status` is the certified read), and that later analyzes must always pass `--embeddings` or lose their vectors, which contradicts Non-negotiable 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(embeddings): one owner for the checkpoint record and the abort predicate Cleanup pass over the #2790 review fixes. No behavior change except where noted; the two exceptions are both cases where the code was lying to the operator or to the other half of itself. The previous pass extracted `core/embedding-count.ts` because two hand-copied bodies of "measure the embedding count" had drifted inside a single change. It then created a second pair of hand-copied publishers — of `RepoMeta.embeddingCheckpoint` — and those had drifted too: the CLI armed the attempt counter only after clearing its identity gate, the server derived it from the resumed marker alone. Only one of the two READERS implemented `kind` at all, so a 'partial' marker written by `gitnexus analyze` and resumed through POST /api/embed still hit the permanent wedge `kind` exists to remove. `core/embedding-checkpoint.ts` now owns the record: `checkpointKind` (the one home for absent-means-interrupted), the three minters, `nextAttemptCount`, and `decideEmbeddingResume`, which both gates route through. Five mint sites and two resume gates become one implementation each. `resilient-fetch.ts` exports `isTerminalNetworkError` and `classifyOutcome` calls it, replacing a caller-side copy of the same DOMException test whose docstring promised it "mirrors classifyOutcome exactly" — an invariant enforced by prose, where a divergence silently reverts body-phase timeouts to being retried three times and charged to the shared breaker. The ratio-guard floor now divides by the run's actual `subBatchSize` instead of a constant 16 that assumed the default of 8. At `subBatchSize: 32` the old formula demanded more sub-batches than the run contains, leaving the guard structurally off — the exact failure the scaled floor was introduced to fix, and sub-batch size is tuned mainly for the flaky endpoints it protects. Two operator-facing corrections: - The count-recovery marker was stamped `kind: 'partial'` with an empty pending set, so `gitnexus status` reported "N node(s) lost their embeddings" where N is zero. It gets its own kind and its own incomplete reason. - `decideEmbeddingResume` initially keyed its skip-the-identity-gate branch on an empty pending set, assuming that meant the count-recovery marker. It does not: `onCheckpoint` mints an 'interrupted' marker with no pending nodes after every post-window save. That silently cleared an interrupted marker under a foreign provider instead of failing closed. Keyed on `kind` now, with a regression test. Also: `isTerminalJobStatus` adopted at the seven sites that still hand-copied it, including the one gating the single-terminal-event emit; `mountSSEProgress` re-export dropped and `server-sse-payload.test.ts` repointed at the extracted module, which takes it from 24.60s to 0.408s — the test that motivated the extraction was still paying the cost it was meant to remove; the count-mismatch message and the SSE test harness deduplicated; per-batch error strings made lazy (~75k needless `new URL()` per large run); `retryable: true` dropped as a field that can never be false; ~110 lines of restated rationale reduced to pointers at their canonical home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
975 lines
41 KiB
TypeScript
975 lines
41 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { getEmbeddingDims, isEmbedderReady } from '../../src/mcp/core/embedder.js';
|
|
|
|
const ENV_KEYS = [
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_API_KEY',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
'GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS',
|
|
'GITNEXUS_EMBEDDING_MAX_ATTEMPTS',
|
|
'GITNEXUS_EMBEDDING_RETRY_CAP_MS',
|
|
'GITNEXUS_EMBEDDING_MIN_INTERVAL_MS',
|
|
'GITNEXUS_EMBEDDING_REQUEST_DIMS',
|
|
] as const;
|
|
|
|
/** 384d mock vector matching the default schema dimensions. */
|
|
const mockVec = Array.from({ length: 384 }, (_, i) => i / 384);
|
|
|
|
describe('HTTP embedding backend', () => {
|
|
// Save original env state before any test mutates it
|
|
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
// Restore env vars to pre-test state so a mid-test throw can't leak
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = savedEnv[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
it('fingerprints HTTP provider identity without confusing a model-only env with HTTP mode', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://user:secret@first.example/v1?token=hidden';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'shared-model-name';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '384';
|
|
const { resolveEmbeddingIdentity } =
|
|
await import('../../src/core/embeddings/embedding-identity.js');
|
|
|
|
const first = resolveEmbeddingIdentity();
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://second.example/v1';
|
|
const second = resolveEmbeddingIdentity();
|
|
delete process.env.GITNEXUS_EMBEDDING_URL;
|
|
const local = resolveEmbeddingIdentity();
|
|
|
|
expect(first.provider).toMatch(/^http:[0-9a-f]{64}$/u);
|
|
expect(first.provider).not.toContain('secret');
|
|
expect(first.provider).not.toContain('hidden');
|
|
expect(second.provider).not.toBe(first.provider);
|
|
expect(local.provider).toBe('local');
|
|
expect(local.model).not.toBe('shared-model-name');
|
|
});
|
|
|
|
describe('MCP embedder', () => {
|
|
it('returns 384 dimensions by default', () => {
|
|
expect(getEmbeddingDims()).toBe(384);
|
|
});
|
|
|
|
it('returns false before initialization', () => {
|
|
expect(isEmbedderReady()).toBe(false);
|
|
});
|
|
|
|
it('returns true when HTTP environment variables are set', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
expect(mod.isEmbedderReady()).toBe(true);
|
|
});
|
|
|
|
it('reads custom dimensions from environment', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
expect(mod.getEmbeddingDims()).toBe(1024);
|
|
});
|
|
|
|
it('retries query on transient server error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 503 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('test query');
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual(mockVec);
|
|
});
|
|
});
|
|
|
|
describe('core embedder HTTP path', () => {
|
|
it('sends correct request payload', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = 'test-key';
|
|
|
|
const mockEmbedding = Array.from({ length: 384 }, (_, i) => i * 0.001);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockEmbedding }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
expect(fetch).toHaveBeenCalledOnce();
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.model).toBe('test-model');
|
|
expect(body.input).toEqual(['test text']);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
expect(result.length).toBe(384);
|
|
});
|
|
|
|
it('omits dimensions from request body when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// GITNEXUS_EMBEDDING_DIMS intentionally unset
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
// Backends that reject unknown fields must see the pre-existing
|
|
// request shape. The field must be absent, not `undefined`.
|
|
expect('dimensions' in body).toBe(false);
|
|
});
|
|
|
|
it('forwards GITNEXUS_EMBEDDING_DIMS as dimensions in request body', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(1024);
|
|
expect(body.model).toBe('text-embedding-3-large');
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('can validate custom dims without forwarding dimensions to strict backends', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(body.model).toBe('bge-m3');
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('forwards dimensions on the single-query path', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec512 }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('query text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(512);
|
|
expect(result.length).toBe(512);
|
|
});
|
|
|
|
it('can omit dimensions on the single-query path while validating custom dims', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('query text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it.each(['none', 'off', 'false', '0'])(
|
|
'treats GITNEXUS_EMBEDDING_REQUEST_DIMS=%s as omit and drops the request dimensions field',
|
|
async (alias) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = alias;
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(result.length).toBe(1024);
|
|
},
|
|
);
|
|
|
|
it('sends REQUEST_DIMS as the request dimensions while DIMS validates the response', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = '512';
|
|
|
|
// Response keeps the DIMS-validated length; only the outgoing request differs.
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(512);
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('rejects a malformed GITNEXUS_EMBEDDING_REQUEST_DIMS with an error naming that var', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'garbage';
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingDimsError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// Recognizable as a config error so the CLI prints a clean message...
|
|
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
|
|
// ...and it points the operator at the var they set, not GITNEXUS_EMBEDDING_DIMS.
|
|
expect(String(err)).toContain('GITNEXUS_EMBEDDING_REQUEST_DIMS must be a positive integer');
|
|
});
|
|
|
|
it('retries on server error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 503 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries on rate limit', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 429 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('throws when all retries are exhausted', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('500');
|
|
// Type-completeness fence: a non-OK-status failure must stay classifiable
|
|
// so the CLI routes it to the endpoint branch, not the HF branch (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
|
|
it('classifies a terminal 4xx (404) as a typed endpoint error without retrying (#2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// The most common --embedding-base-url misconfiguration: wrong path -> 404,
|
|
// bad key -> 401/403. resilientFetch returns a terminal 4xx (other than 429)
|
|
// without retrying, so httpEmbedBatch's !resp.ok branch is the sole
|
|
// classifier — distinct from 500 (ResilientFetchExhaustedError) and 429/503.
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('404');
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('classifies a reachable endpoint that returns a non-JSON 200 body', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// An unusable 2xx body is retried like a 5xx (#2790); collapse the
|
|
// backoff so this case still asserts classification, not wall clock.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
// A captive portal / wrong service answers 200 with HTML — resp.json() throws.
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token < in JSON at position 0');
|
|
},
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unparseable response');
|
|
});
|
|
|
|
it('surfaces a connection failure as a typed HttpEmbeddingError (the #2385 case)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://127.0.0.1:1/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Node's undici throws `TypeError: fetch failed` on a terminal connect error.
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// The endpoint failure carries the type — no message-text matching needed.
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The masked URL is preserved for the CLI message; no HuggingFace text.
|
|
expect(String(err)).toContain('127.0.0.1:1');
|
|
expect(String(err)).not.toMatch(/huggingface/i);
|
|
});
|
|
|
|
// A reachable-but-wrong endpoint can answer 200 with a well-formed outer array
|
|
// whose items are malformed. The outer Array.isArray(data.data) guard passes;
|
|
// without per-item validation these crash at new Float32Array(item.embedding)
|
|
// (batch) / items[0].embedding (query) with a raw TypeError that escapes the
|
|
// typed boundary — the exact #2385 stack-dump class. (#2385)
|
|
it.each([
|
|
{ label: 'a null item', body: { data: [null] } },
|
|
{ label: 'an item with no embedding', body: { data: [{}] } },
|
|
{ label: 'an item whose embedding is not an array', body: { data: [{ embedding: 'nope' }] } },
|
|
])('types a malformed response item ($label) on the batch path', async ({ body }) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Wrong-shaped 2xx bodies are retried too (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => body }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unexpected response shape');
|
|
});
|
|
|
|
it('types a null item on the query path (httpEmbedQuery, #2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Wrong-shaped 2xx bodies are retried too (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [null] }) }),
|
|
);
|
|
|
|
const { httpEmbedQuery, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
const err = await httpEmbedQuery('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unexpected response shape');
|
|
});
|
|
|
|
it('excludes API key from error messages', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
const redactionProbeKey = 'test-api-key-redaction-check';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = redactionProbeKey;
|
|
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
try {
|
|
await embedText('test');
|
|
} catch (e: any) {
|
|
expect(e.message).not.toContain(redactionProbeKey);
|
|
expect(e.message).not.toContain('Authorization');
|
|
}
|
|
});
|
|
|
|
it('scrubs credentials embedded in the endpoint URL from the error message (#2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://user:secret@host.example/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// undici rejects a credential-bearing URL at Request construction, echoing
|
|
// the full URL (incl. user:secret) verbatim in err.message.
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new TypeError(
|
|
'Request cannot be constructed from a URL that includes credentials: ' +
|
|
'https://user:secret@host.example/v1/embeddings',
|
|
),
|
|
),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The secret is gone; the masked host is retained so the message stays useful.
|
|
expect(String(err)).not.toContain('secret');
|
|
expect(String((err as Error & { cause?: unknown }).cause)).not.toContain('secret');
|
|
expect(String(err)).toContain('host.example');
|
|
});
|
|
|
|
it('redacts the API key from both the message and diagnostic cause', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://host.example/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = 'super-secret-key';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValue(new TypeError('transport rejected super-secret-key')),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const err = await embedText('test').catch((error: unknown) => error);
|
|
expect(String(err)).not.toContain('super-secret-key');
|
|
expect(String((err as Error & { cause?: unknown }).cause)).not.toContain('super-secret-key');
|
|
});
|
|
|
|
it('leaves a non-credential reason unchanged (no over-scrubbing)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('fetch failed');
|
|
});
|
|
|
|
it('includes abort signal for timeout', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
|
|
const opts = (fetch as any).mock.calls[0][1];
|
|
expect(opts.signal).toBeDefined();
|
|
});
|
|
|
|
it('splits large inputs into batches', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const makeResp = (n: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: mockVec })) }),
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce(makeResp(64)).mockResolvedValueOnce(makeResp(6)),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(results).toHaveLength(70);
|
|
});
|
|
|
|
it('forwards dimensions in every batch when splitting large inputs', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
|
const makeResp = (n: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: vec512 })) }),
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce(makeResp(64)).mockResolvedValueOnce(makeResp(6)),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(results).toHaveLength(70);
|
|
|
|
// Verify dimensions is sent in BOTH batch requests
|
|
const body0 = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
const body1 = JSON.parse((fetch as any).mock.calls[1][1].body);
|
|
expect(body0.dimensions).toBe(512);
|
|
expect(body1.dimensions).toBe(512);
|
|
});
|
|
|
|
it('rejects non-numeric GITNEXUS_EMBEDDING_DIMS values', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('must be a positive integer');
|
|
});
|
|
|
|
it('rejects initEmbedder when using HTTP backend', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { initEmbedder } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(initEmbedder()).rejects.toThrow('HTTP mode');
|
|
});
|
|
|
|
it('rejects getEmbedder when using HTTP backend', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { getEmbedder } = await import('../../src/core/embeddings/embedder.js');
|
|
expect(() => getEmbedder()).toThrow('HTTP embedding mode');
|
|
});
|
|
|
|
it('throws on empty response from endpoint', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// A short body is retried like a 5xx now (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await mod.embedQuery('test').catch((e: unknown) => e);
|
|
// `{"data": []}` is a cardinality mismatch caught inside the retry loop,
|
|
// so it reports both counts rather than reaching httpEmbedQuery's
|
|
// (now defensive) "empty response" backstop (#2790).
|
|
expect(String(err)).toContain('0 vectors for 1 texts');
|
|
// Type-completeness fence: this conversion must stay typed so the CLI
|
|
// routes it to the endpoint branch, not the HF branch (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('throws when endpoint returns fewer embeddings than texts', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// A short body is retried like a 5xx now (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedBatch(['text1', 'text2', 'text3']).catch((e: unknown) => e);
|
|
expect(String(err)).toContain('1 vectors for 3 texts');
|
|
// Type-completeness fence (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The short body now gets the full retry budget instead of failing after
|
|
// one attempt with a `recordSuccess()` already booked (#2790).
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('throws on dimension mismatch when GITNEXUS_EMBEDDING_DIMS is set', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('Embedding dimension mismatch');
|
|
// Type-completeness fence (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('schema dimensions', () => {
|
|
it('defaults to 384 dimensions', async () => {
|
|
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
|
|
expect(EMBEDDING_DIMS).toBe(384);
|
|
});
|
|
|
|
it('reads dimensions from environment variable', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
|
|
expect(EMBEDDING_DIMS).toBe(1024);
|
|
});
|
|
});
|
|
|
|
describe('timeout and network error handling', () => {
|
|
it('uses a 180-second default timeout and accepts a bounded override', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { getHttpTimeoutMs } = await import('../../src/core/embeddings/http-client.js');
|
|
expect(getHttpTimeoutMs()).toBe(180_000);
|
|
|
|
process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '120000';
|
|
expect(getHttpTimeoutMs()).toBe(120_000);
|
|
process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '300001';
|
|
expect(() => getHttpTimeoutMs()).toThrow('GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS');
|
|
});
|
|
|
|
it('does not retry on timeout', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const timeoutErr = new DOMException(
|
|
'The operation was aborted due to timeout',
|
|
'TimeoutError',
|
|
);
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeoutErr));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('timed out');
|
|
// Type-completeness fence: a timeout must stay classifiable (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries on network error then succeeds', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValueOnce(new TypeError('fetch failed')).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
});
|
|
|
|
it('honors the configured total attempt bound', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '1';
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('503');
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('caps Retry-After with the configured retry cap', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '2500';
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('{}', { status: 429, headers: { 'Retry-After': '60' } }),
|
|
)
|
|
.mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedText('test');
|
|
await vi.advanceTimersByTimeAsync(2499);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(promise).resolves.toBeInstanceOf(Float32Array);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('paces retries and successful batches through one minimum-interval queue', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
process.env.GITNEXUS_EMBEDDING_MIN_INTERVAL_MS = '1000';
|
|
const makeResp = (count: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: count }, () => ({ embedding: mockVec })) }),
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: false, status: 503 })
|
|
.mockResolvedValueOnce(makeResp(64))
|
|
.mockResolvedValueOnce(makeResp(6)),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(promise).resolves.toHaveLength(70);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('cancels promptly while waiting for retry backoff', async () => {
|
|
vi.useFakeTimers();
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '3';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '60000';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValue(new Response('{}', { status: 429, headers: { 'Retry-After': '60' } })),
|
|
);
|
|
const controller = new AbortController();
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedBatch(['test'], { signal: controller.signal });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
controller.abort();
|
|
await expect(promise).rejects.toThrow(/cancelled/i);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
['GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS', '0'],
|
|
['GITNEXUS_EMBEDDING_MAX_ATTEMPTS', '0'],
|
|
['GITNEXUS_EMBEDDING_RETRY_CAP_MS', '-1'],
|
|
['GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 'nope'],
|
|
])('rejects malformed resilience config %s=%s', async (key, value) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env[key] = value;
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow(key);
|
|
});
|
|
});
|
|
|
|
describe('dimension mismatch on query path', () => {
|
|
it('throws on explicit dim mismatch in embedQuery', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await mod.embedQuery('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('dimension mismatch');
|
|
// Type-completeness fence: the query-path conversion must stay typed (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
|
|
it('throws with Set hint when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const vec768 = Array.from({ length: 768 }, (_, i) => i / 768);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec768 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('Set GITNEXUS_EMBEDDING_DIMS=768');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('HttpEmbeddingError classification', () => {
|
|
it('recognises an HttpEmbeddingError instance', async () => {
|
|
const { HttpEmbeddingError, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
expect(isHttpEmbeddingError(new HttpEmbeddingError('anything at all'))).toBe(true);
|
|
});
|
|
|
|
it('recognises a cross-realm error by name even when instanceof fails', async () => {
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
// Simulates an error that crossed a module boundary and lost its prototype
|
|
// chain: instanceof would be false, but the stable `name` still identifies it.
|
|
const crossRealm = new Error('endpoint down');
|
|
crossRealm.name = 'HttpEmbeddingError';
|
|
expect(isHttpEmbeddingError(crossRealm)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
new Error('TypeError: fetch failed'),
|
|
new Error('Failed to download embedding model'),
|
|
new Error('connect ECONNREFUSED 127.0.0.1:443'),
|
|
'not even an error',
|
|
undefined,
|
|
])('does not claim non-endpoint value: %s', async (value) => {
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
expect(isHttpEmbeddingError(value)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('HTTP mode config probe (#2385)', () => {
|
|
const ENV_KEYS = [
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
] as const;
|
|
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = savedEnv[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
it('isHttpMode() is a presence probe that does NOT throw on a malformed DIMS', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
const { isHttpMode } = await import('../../src/core/embeddings/http-client.js');
|
|
// Root-cause fix: the mode probe must not validate DIMS, so ~13 unguarded
|
|
// call sites (analyze:1109, doctor, run-analyze, embedder, mcp) don't crash.
|
|
expect(isHttpMode()).toBe(true);
|
|
});
|
|
|
|
it('surfaces a malformed DIMS as a recognizable plain config error, not an endpoint error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingDimsError, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// Validated where it's used (readConfig in httpEmbed) and recognizable...
|
|
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
|
|
// ...as a plain config Error, NOT an HttpEmbeddingError endpoint failure.
|
|
expect(isHttpEmbeddingError(err)).toBe(false);
|
|
});
|
|
});
|