Files
OmniRoute/tests/unit/agenticConversations.test.ts
Praveen K Palaniswamy 65e81158ab fix(ollama): route models by advertised capability (#11088)
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
2026-08-23 11:45:01 -03:00

300 lines
13 KiB
TypeScript

/**
* Unit tests for src/lib/db/agenticConversations.ts CRUD.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-agentic-conv-db-"));
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agentic-conversations-test-secret";
// Dynamic imports (not static) are required here: a static `import` of a module
// that reads process.env.DATA_DIR at its own top level (src/lib/db/core.ts's
// `export const DATA_DIR = ...`) is evaluated before this file's own top-level
// code runs — ESM instantiates the whole dependency graph, dependencies first,
// regardless of source-line order — so the override above would silently miss
// and the module would resolve the real host DATA_DIR instead of the temp dir.
const {
createAgenticConversation,
findAgenticConversationsByFingerprint,
updateAgenticConversation,
touchOrCreateExternalConversation,
listMultiTurnConversations,
getConversationTurnIndex,
insertConversationTurnNodes,
getConversationTurnPage,
resolveCallLogIdsByCorrelationIds,
} = await import("../../src/lib/db/agenticConversations.ts");
const { getDbInstance } = await import("../../src/lib/db/core.ts");
test("createAgenticConversation + findAgenticConversationsByFingerprint round-trip", () => {
const row = createAgenticConversation({
apiKeyId: "key-a",
fingerprintHash: "fp-round-trip",
});
assert.match(row.id, /^conv_/);
assert.equal(row.turnCount, 1);
const found = findAgenticConversationsByFingerprint("fp-round-trip");
assert.equal(found.length, 1);
assert.equal(found[0].id, row.id);
assert.equal(found[0].apiKeyId, "key-a");
});
test("findAgenticConversationsByFingerprint returns multiple rows for a shared fingerprint", () => {
createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" });
createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" });
const found = findAgenticConversationsByFingerprint("fp-shared");
assert.equal(found.length, 2);
});
test("updateAgenticConversation updates turn count", () => {
const row = createAgenticConversation({ apiKeyId: "key-c", fingerprintHash: "fp-update" });
updateAgenticConversation(row.id, { turnCount: 3 });
const found = findAgenticConversationsByFingerprint("fp-update");
assert.equal(found[0].turnCount, 3);
});
test("insertConversationTurnNodes + getConversationTurnIndex round-trip", () => {
const row = createAgenticConversation({ apiKeyId: "key-nodes", fingerprintHash: "fp-nodes" });
insertConversationTurnNodes(row.id, "corr-1", [
{ id: "node-a", parentId: null, role: "user", contentHash: "hash-a" },
{ id: "node-b", parentId: "node-a", role: "assistant", contentHash: "hash-b" },
]);
const index = getConversationTurnIndex(row.id);
assert.equal(index.nodeIds.size, 2);
assert.ok(index.nodeIds.has("node-a"));
assert.ok(index.nodeIds.has("node-b"));
assert.deepEqual(index.byContentHash.get("hash-a"), ["node-a"]);
assert.deepEqual(index.byContentHash.get("hash-b"), ["node-b"]);
// A different conversation's nodes must never leak into this index.
const other = createAgenticConversation({
apiKeyId: "key-nodes-2",
fingerprintHash: "fp-nodes-2",
});
insertConversationTurnNodes(other.id, "corr-2", [
{ id: "node-c", parentId: null, role: "user", contentHash: "hash-c" },
]);
const reReadIndex = getConversationTurnIndex(row.id);
assert.equal(reReadIndex.nodeIds.size, 2);
assert.equal(reReadIndex.byContentHash.has("hash-c"), false);
});
test("getConversationTurnIndex groups multiple node ids under the same content hash (duplicate turn text at different tree positions)", () => {
const row = createAgenticConversation({ apiKeyId: "key-dup-content", fingerprintHash: "fp-dup" });
insertConversationTurnNodes(row.id, "corr-1", [
{ id: "node-1", parentId: null, role: "user", contentHash: "hash-ok" },
{ id: "node-2", parentId: "node-1", role: "assistant", contentHash: "hash-reply" },
// Same content ("ok") recurs later in the same tree, at a different node.
{ id: "node-3", parentId: "node-2", role: "user", contentHash: "hash-ok" },
]);
const index = getConversationTurnIndex(row.id);
const matches = index.byContentHash.get("hash-ok");
assert.equal(matches?.length, 2);
assert.deepEqual([...matches!].sort(), ["node-1", "node-3"]);
});
test("insertConversationTurnNodes is idempotent for already-existing node ids (INSERT OR IGNORE)", () => {
const row = createAgenticConversation({ apiKeyId: "key-idem", fingerprintHash: "fp-idem" });
insertConversationTurnNodes(row.id, "corr-1", [
{ id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" },
]);
// Re-insert the same id — must not throw, must not duplicate.
insertConversationTurnNodes(row.id, "corr-2", [
{ id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" },
]);
const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes;
assert.equal(tree.length, 1);
});
test("getConversationTurnPage returns the full chain with parent/child structure and content hash", () => {
const row = createAgenticConversation({ apiKeyId: "key-tree", fingerprintHash: "fp-tree" });
insertConversationTurnNodes(row.id, "corr-tree", [
{ id: "root-turn", parentId: null, role: "user", contentHash: "hash-hello" },
{ id: "child-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hi" },
]);
// A sibling branch off the same parent.
insertConversationTurnNodes(row.id, "corr-tree-2", [
{ id: "sibling-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hey" },
]);
const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes;
assert.equal(tree.length, 3);
const root = tree.find((n) => n.id === "root-turn");
const children = tree.filter((n) => n.parentId === "root-turn");
assert.equal(root?.parentId, null);
assert.equal(root?.contentHash, "hash-hello");
assert.equal(children.length, 2);
assert.deepEqual(children.map((c) => c.id).sort(), ["child-turn", "sibling-turn"]);
});
test("getConversationTurnPage: initial load returns only the last `limit` turns, oldest-first, with hasMore", () => {
const row = createAgenticConversation({ apiKeyId: "key-page", fingerprintHash: "fp-page" });
const nodes = Array.from({ length: 25 }, (_, i) => ({
id: `n${i}`,
parentId: i === 0 ? null : `n${i - 1}`,
role: i % 2 === 0 ? "user" : "assistant",
contentHash: `hash-${i}`,
}));
insertConversationTurnNodes(row.id, "corr-page", nodes);
const page = getConversationTurnPage(row.id, { limit: 20 });
assert.equal(page.nodes.length, 20);
assert.equal(page.hasMore, true);
// Oldest-first within the page, and it's the LAST 20 (n5..n24).
assert.equal(page.nodes[0].id, "n5");
assert.equal(page.nodes[19].id, "n24");
});
test("getConversationTurnPage: beforeSeq loads the previous page (older turns), with correct hasMore", () => {
const row = createAgenticConversation({ apiKeyId: "key-page-2", fingerprintHash: "fp-page-2" });
const nodes = Array.from({ length: 25 }, (_, i) => ({
id: `m${i}`,
parentId: i === 0 ? null : `m${i - 1}`,
role: "user",
contentHash: `hash-m${i}`,
}));
insertConversationTurnNodes(row.id, "corr-page-2", nodes);
const firstPage = getConversationTurnPage(row.id, { limit: 20 });
const oldestSeqInFirstPage = firstPage.nodes[0].seq;
const olderPage = getConversationTurnPage(row.id, { limit: 20, beforeSeq: oldestSeqInFirstPage });
assert.equal(olderPage.nodes.length, 5, "only 5 turns (0-4) exist before the first page");
assert.equal(olderPage.hasMore, false);
assert.equal(olderPage.nodes[0].id, "m0");
assert.equal(olderPage.nodes[4].id, "m4");
});
test("getConversationTurnPage: afterSeq returns only turns newer than the cursor (for polling), uncapped", () => {
const row = createAgenticConversation({ apiKeyId: "key-page-3", fingerprintHash: "fp-page-3" });
insertConversationTurnNodes(row.id, "corr-page-3", [
{ id: "p0", parentId: null, role: "user", contentHash: "h0" },
{ id: "p1", parentId: "p0", role: "assistant", contentHash: "h1" },
]);
const firstPage = getConversationTurnPage(row.id, { limit: 20 });
const newestSeq = firstPage.nodes[firstPage.nodes.length - 1].seq;
// Nothing new yet.
assert.equal(getConversationTurnPage(row.id, { afterSeq: newestSeq }).nodes.length, 0);
// A new turn arrives (e.g. a later request continuing this conversation).
insertConversationTurnNodes(row.id, "corr-page-3b", [
{ id: "p2", parentId: "p1", role: "user", contentHash: "h2" },
]);
const polled = getConversationTurnPage(row.id, { afterSeq: newestSeq });
assert.equal(polled.nodes.length, 1);
assert.equal(polled.nodes[0].id, "p2");
assert.equal(polled.hasMore, false);
});
test("touchOrCreateExternalConversation creates then increments turn_count on repeat calls", () => {
const id = "ext-conv-test-id";
touchOrCreateExternalConversation(id, { apiKeyId: "key-d" });
const db = getDbInstance();
const afterCreate = db
.prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?")
.get(id) as { turn_count: number };
assert.equal(afterCreate.turn_count, 1);
touchOrCreateExternalConversation(id, { apiKeyId: "key-d" });
const afterTouch = db
.prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?")
.get(id) as { turn_count: number };
assert.equal(afterTouch.turn_count, 2);
});
test("listMultiTurnConversations only returns conversations with >= 2 actual turn nodes, joined to their latest call_logs row", () => {
const db = getDbInstance();
createAgenticConversation({
id: "conv-single-turn",
apiKeyId: null,
fingerprintHash: "fp-single",
});
insertConversationTurnNodes("conv-single-turn", "corr-single", [
{ id: "single-node-1", parentId: null, role: "user", contentHash: "hash-single-1" },
]);
const multi = createAgenticConversation({
id: "conv-multi-turn",
apiKeyId: null,
fingerprintHash: "fp-multi",
});
// turn_count deliberately left at its default of 1 here: it tracks
// requests-touched, not node count, and a freshly-minted conversation can
// already carry many turn nodes from a single insert (see the doc comment
// on listMultiTurnConversations) — the filter must key off actual node
// count, not turn_count, for this conversation to be listed at all.
insertConversationTurnNodes(multi.id, "corr-multi", [
{ id: "multi-node-1", parentId: null, role: "user", contentHash: "hash-multi-1" },
{
id: "multi-node-2",
parentId: "multi-node-1",
role: "assistant",
contentHash: "hash-multi-2",
},
]);
db.prepare(
`INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag)
VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', 'opencode-zen', ?)`
).run("multi-turn-1", "2026-03-01T00:00:00.000Z", "conv-multi-turn");
db.prepare(
`INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag)
VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'gemma-4', 'gemini', ?)`
).run("multi-turn-2", "2026-03-01T00:01:00.000Z", "conv-multi-turn");
const { rows, total } = listMultiTurnConversations();
const ids = rows.map((r) => r.id);
assert.ok(ids.includes("conv-multi-turn"));
assert.ok(!ids.includes("conv-single-turn"));
assert.ok(total >= 1);
const found = rows.find((r) => r.id === "conv-multi-turn");
assert.equal(found?.lastCallLogId, "multi-turn-2");
assert.equal(found?.lastModel, "gemma-4");
assert.equal(found?.lastProvider, "gemini");
});
test("resolveCallLogIdsByCorrelationIds bulk-resolves correlation_id to call_logs.id", () => {
const db = getDbInstance();
db.prepare(
`INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id)
VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)`
).run("call-corr-1", "2026-04-01T00:00:00.000Z", "corr-a");
db.prepare(
`INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id)
VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)`
).run("call-corr-2", "2026-04-01T00:01:00.000Z", "corr-b");
const resolved = resolveCallLogIdsByCorrelationIds(["corr-a", "corr-b", "corr-missing"]);
assert.equal(resolved.get("corr-a"), "call-corr-1");
assert.equal(resolved.get("corr-b"), "call-corr-2");
assert.equal(resolved.has("corr-missing"), false);
});
test("resolveCallLogIdsByCorrelationIds returns an empty map for an empty/all-falsy input", () => {
assert.equal(resolveCallLogIdsByCorrelationIds([]).size, 0);
assert.equal(resolveCallLogIdsByCorrelationIds(["", ""]).size, 0);
});