Files
OmniRoute/tests/unit/agenticConversations.test.ts
Markus Hartung beb6ec857b feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)
* feat(responses): virtualize previous_response_id continuation regardless of upstream support

OmniRoute now exposes OpenAI-compatible previous_response_id/store
continuation to clients unconditionally, even when the selected upstream
provider has no native Responses-API state support. Reconstruction happens
server-side in handleChatImplementation, before any downstream validation
or provider translation: OmniRoute resolves the response id back to the
full input/output it previously produced, prepends it to the client's
delta, and forwards the full reconstructed history upstream exactly as it
does today. Client<->OmniRoute traffic shrinks to the new delta only;
OmniRoute<->provider traffic is unchanged.

Storage reuses the existing call-log pipeline artifact (already gated by
call_log_pipeline_enabled, already retained/cleaned up by the existing
call-log lifecycle) instead of duplicating conversation content into a
second store -- only a lightweight call_logs.response_id index is new.
Every lookup is scoped by api_key_id so one client can never resolve
another client's stored conversation, and any unresolvable/missing/
size-limit-omitted state fails closed with OpenAI's own
previous_response_not_found contract.

Stacked on feat/openai-responses-store-toggle (#10121).

* feat(dashboard): agentic conversation tracking with live transcript view

Every agentic chat request now gets a conversation id (X-ConversationId
response header). OmniRoute detects when a follow-up request continues the
same conversation via fingerprint + bounded prefix-hash matching, with a
strict-growth invariant to prevent false merges between independent
single-shot requests that happen to share identical opening content.
Continuation detection excludes the system message from the identity
anchor, since real coding-agent CLIs commonly regenerate it every request
with live context (timestamp, cwd, git status) — without this, that
volatility alone broke every continuation check against real traffic.

- `/dashboard/logs`: new toggleable Conversation column.
- `/dashboard/logs/timeline`: requests sharing a conversation id share a
  timeline lane, connected by an arrow, with a configurable lane-reuse
  window.
- Request detail panel: new Full Conversation transcript above the raw SSE
  event stream — Markdown rendering, per-turn timestamps, turn-relative
  view, click-any-turn navigation, live auto-refresh building the
  transcript in real time from the in-flight SSE chunk buffer while a
  request is still streaming, auto-scroll-to-bottom as the live turn grows.
- New `/dashboard/conversations` page listing conversations with 2+ turns,
  no-forking model (an edited/duplicated mid-history turn mints its own
  independent conversation instead of merging), pagination, duplicate-
  anchor fix.
- Configurable auto-refresh intervals on both the timeline and
  conversations list pages.
- Responses API tool-call gap fix: turnsFromOpenAiMessages only handled
  role-based Chat Completions messages, so bare {type:"function_call"} /
  {type:"function_call_output"} / {type:"reasoning"} items (real Responses
  API traffic) silently vanished from the Conversation Context panel.
- truncateForLog now counts input[] (Responses API), not just messages[]
  (Chat Completions), so a truncated /v1/responses request still shows a
  placeholder instead of nothing.
- RequestTimeline.tsx now reads the same debugEnabled/emailsVisible
  settings RequestLoggerV2.tsx already used, instead of hardcoding both
  false — the timeline view never showed SSE/stream-chunk events or
  respected email-masking, regardless of the actual setting.

Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135
and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this
uses the next free slot past upstream's current highest.

Test plan:
- npm run typecheck:core — clean
- npm run lint — clean
- node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions
- 109 unit tests across the conversation-tracking, migration-renumber, and
  dashboard-wiring surface — 0 failures

* refactor(dashboard): reuse call-log artifacts for conversation transcript content

conversation_turn_nodes no longer stores turn text/tool-call content
(text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/
content_hash), matching agentic_conversations' existing lightweight-index
shape. Every node's originating request is already fully captured by the
call-log pipeline artifact its last_correlation_id points at, so the
/dashboard/conversations tree view resolves each node's actual display
content on demand from there (open-sse/services/conversationTurnContent.ts),
re-running the same extractCanonicalTurns/hashTurnContent the write path
used and matching by content_hash, instead of duplicating conversation
content into a second store under a separate retention/gating policy. This
also drops the old 8000-char text_preview truncation entirely -- resolved
content is always full and untruncated.

The frontend contract is unchanged (tree API still returns
{textPreview, blockKind, toolName} per node), so the dashboard UI itself
(page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no
changes.

Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now
collides with 147_api_keys_model_access_mode.sql, which landed on
release/v3.8.50 after this work was originally built.

Also includes a standalone, unrelated fix carried along from this rebase:
close isProviderModelHidden's missing function-body brace in
modelSelectModalHelpers.ts (separately landed as #10206).

Stacked on feat/responses-previous-response-id-virtualization (#3), which
is itself stacked on feat/openai-responses-store-toggle (#10121).

* fix(dashboard): resync conversation list on open so the live-text poll starts immediately

openConversation() seeded activeConversation (and therefore activeCallLogId,
which gates the live-partial-text poll effect) from whatever row snapshot the
list's own fixed-interval poll last produced. A conversation opened right
after a reply started streaming -- after that tick, before the next -- had
activeCallLogId still null, so the live-text poll never started; only a
subsequent background list-poll resync (already existed) picked it up,
which is why closing and reopening the same conversation "just worked".

loadConversations() is now a shared callback so openConversation can force
one immediately on open instead of waiting on pollSeconds.

Live-verified against omniroute-dev: opening a conversation mid-stream now
shows live reasoning on the first open.

* style: prettier formatting for conversationTurnContent.test.ts

* fix(db): close migration numbering gap left by decoupling from #3/#10262

153/154 (originally 154/155) were chosen back when this branch stacked on
top of the previous_response_id migration (153_call_logs_response_id.sql).
Decoupling removed that migration from this branch's history, leaving an
unused 153 slot that check-migration-numbering.test.ts correctly flags as
a gap.

* refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap

Both files exceeded check-file-size's new-file cap after this PR's own
additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure
non-component logic (types, constants, allocateLanes and its helpers) out
of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two
self-contained presentational sub-components (PayloadSection,
ConversationContextSection + its private helper) out of
RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior
change; existing external imports (default exports, allocateLanes,
TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the
original file paths.

* fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs

The refresh-merge of release/v3.8.50 exposed that the feature's three
migrations collided at slot 153 with the base's radar_local_model_state
(153) and its own call_logs_response_id. Migration runner enforces unique
numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and
the DB-backed quality gates. Renumber the feature's pair to
155_agentic_conversations / 156_conversation_turn_nodes and move
call_logs_response_id to 154 (keeps 153_radar base-owned, preserves
agentic-before-turn_nodes ordering). Update SQL headers and the
154/156 references in feature code + tests.

Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync
the doc counts to clear the docs-accuracy gate.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline

Knip 6.32 (baseline 415) flags the public re-export of
CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no
external consumer imports it through that re-export (it is imported and
used directly from RequestTimeline.utils.ts inside the component). Removed
the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415,
back to the frozen baseline.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export

- Wrap resolveConversationId() in try/catch in chat.ts, matching the
  defensive pattern used by every other best-effort side call nearby, so a
  DB hiccup in conversation tracking can't turn a working chat request into
  a hard failure.
- Remove getConversationTurnTree: knip's project scope excludes tests/**,
  so an export used only by tests can never register as used there. Swap
  its 8 test call sites to the paginated getConversationTurnPage (already
  the dashboard's canonical query) with a generous limit, collapsing to one
  query path instead of keeping a second whole-chain export alive solely
  for test convenience.
- Regenerate i18n llm.txt mirrors from root (pre-existing drift on this
  branch, unrelated to the above, caught by the docs-sync pre-commit gate).

Addresses PR review feedback.

* fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift

- fr.json, vi.json were missing requestLogger.columns.conversation (added
  in the conversation-tracking feature), failing i18n-vi-completeness.test.ts.
- docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an
  earlier rebase fixed the migration count but missed this companion number,
  failing check-docs-sync.mjs across all 42 locales.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red)

.env.example and docs/reference/ENVIRONMENT.md were both missing the
PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads
(confirmed present at this branch's merge-base too, so this predates
the conversation-tracking work and is unrelated to it) -- the entry
was added on release/v3.8.50 after this branch's last sync and this
branch never picked it up. That gap red-lines
tests/unit/check-env-doc-sync.test.ts and
tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests
fast-path 2/4 in CI). Restore both entries verbatim from the current
release/v3.8.50 tip -- no feature-code change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:32:33 -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);
});