mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Every conversation is now a single straight line: when a request's turn history diverges from what's on file (real OpenClaw traffic edits/ duplicates turns to keep provider-side prompt caches warm), the diverging history mints its own independent conversation instead of forking a branch inside the old one. Distinguished via anchorHasChild — whether the reconnect anchor already has a recorded child. Also fixes the actual production-blocking bug this surfaced: real agentic traffic is full of byte-identical repeated turns (tool-polling loop output, heartbeat acks — one real conversation had 28 duplicates of a single turn). findReconnectMatch used to return on the first candidate anchor found for a repeated turn's content hash — in practice the oldest, stalest occurrence — whose recorded next-turn differs from the current request, so it looked like a divergence on every single request instead of ever reconnecting. Now every candidate anchor is evaluated and the one that verifiably extends furthest wins (ties break toward the anchor with no recorded child). Dashboard: /dashboard/conversations lists conversations by actual turn-node count instead of request-touch count (a freshly-forked conversation can carry hundreds of turns from a single insert but start at turn_count=1, which wrongly excluded it from the old turn_count>=2 filter). The conversation view loads the last 20 turns with a "Load more" button, scrolls to bottom on open and stays pinned there via a ResizeObserver while large/late-settling content keeps growing (a single requestAnimationFrame undershoots for a page containing multi-KB tool-output turns), and resyncs its "Goto latest request"/summary fields from the background list poll so they don't go stale while the modal stays open (keyed off the id, not the whole row object, so the poll-for-new-turns interval isn't reset every tick by that resync). X-ConversationId threading: chat.ts now passes the request's own correlationId into resolveConversationId so new turn-chain nodes can be tagged with a request identifier that exists before the call_logs row itself does. usageHistory's in-memory pending-request state (byModel/ byAccount/details/pendingById) is reused across Next.js dev HMR module re-evaluations via a globalThis singleton (same pattern as db/core.ts), so a live poll against a request that started before a hot-reload doesn't silently lose its partialAssistantText/isActive tracking.
432 lines
14 KiB
TypeScript
432 lines
14 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";
|
|
|
|
import {
|
|
createAgenticConversation,
|
|
findAgenticConversationsByFingerprint,
|
|
updateAgenticConversation,
|
|
touchOrCreateExternalConversation,
|
|
listMultiTurnConversations,
|
|
getConversationTurnIndex,
|
|
insertConversationTurnNodes,
|
|
getConversationTurnTree,
|
|
getConversationTurnPage,
|
|
resolveCallLogIdsByCorrelationIds,
|
|
} from "../../src/lib/db/agenticConversations.ts";
|
|
import { getDbInstance } from "../../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",
|
|
textPreview: "a",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
{
|
|
id: "node-b",
|
|
parentId: "node-a",
|
|
role: "assistant",
|
|
contentHash: "hash-b",
|
|
textPreview: "b",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
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",
|
|
textPreview: "c",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
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",
|
|
textPreview: "ok",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
{
|
|
id: "node-2",
|
|
parentId: "node-1",
|
|
role: "assistant",
|
|
contentHash: "hash-reply",
|
|
textPreview: "reply",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
// Same content ("ok") recurs later in the same tree, at a different node.
|
|
{
|
|
id: "node-3",
|
|
parentId: "node-2",
|
|
role: "user",
|
|
contentHash: "hash-ok",
|
|
textPreview: "ok",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
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",
|
|
textPreview: "first",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
// 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",
|
|
textPreview: "first",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
const tree = getConversationTurnTree(row.id);
|
|
assert.equal(tree.length, 1);
|
|
});
|
|
|
|
test("getConversationTurnTree returns nodes with parent/child structure and text preview", () => {
|
|
const row = createAgenticConversation({ apiKeyId: "key-tree", fingerprintHash: "fp-tree" });
|
|
|
|
insertConversationTurnNodes(row.id, "corr-tree", [
|
|
{
|
|
id: "root-turn",
|
|
parentId: null,
|
|
role: "user",
|
|
contentHash: "hash-hello",
|
|
textPreview: "hello",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
{
|
|
id: "child-turn",
|
|
parentId: "root-turn",
|
|
role: "assistant",
|
|
contentHash: "hash-hi",
|
|
textPreview: "hi there",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
// A sibling branch off the same parent.
|
|
insertConversationTurnNodes(row.id, "corr-tree-2", [
|
|
{
|
|
id: "sibling-turn",
|
|
parentId: "root-turn",
|
|
role: "assistant",
|
|
contentHash: "hash-hey",
|
|
textPreview: "hey",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
const tree = getConversationTurnTree(row.id);
|
|
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?.textPreview, "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}`,
|
|
textPreview: `turn-${i}`,
|
|
blockKind: "text",
|
|
toolName: null,
|
|
}));
|
|
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 (turn-5..turn-24).
|
|
assert.equal(page.nodes[0].textPreview, "turn-5");
|
|
assert.equal(page.nodes[19].textPreview, "turn-24");
|
|
});
|
|
|
|
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}`,
|
|
textPreview: `turn-${i}`,
|
|
blockKind: "text",
|
|
toolName: null,
|
|
}));
|
|
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].textPreview, "turn-0");
|
|
assert.equal(olderPage.nodes[4].textPreview, "turn-4");
|
|
});
|
|
|
|
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",
|
|
textPreview: "a",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
{
|
|
id: "p1",
|
|
parentId: "p0",
|
|
role: "assistant",
|
|
contentHash: "h1",
|
|
textPreview: "b",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
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",
|
|
textPreview: "c",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
const polled = getConversationTurnPage(row.id, { afterSeq: newestSeq });
|
|
assert.equal(polled.nodes.length, 1);
|
|
assert.equal(polled.nodes[0].textPreview, "c");
|
|
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",
|
|
textPreview: "hi",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
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",
|
|
textPreview: "hi",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
{
|
|
id: "multi-node-2",
|
|
parentId: "multi-node-1",
|
|
role: "assistant",
|
|
contentHash: "hash-multi-2",
|
|
textPreview: "hello",
|
|
blockKind: "text",
|
|
toolName: null,
|
|
},
|
|
]);
|
|
|
|
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);
|
|
});
|