mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
feat(conversations): redesign to no-forking model with pagination and duplicate-anchor fix
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.
This commit is contained in:
@@ -3,9 +3,28 @@
|
||||
* HTTP requests that are turns of the same multi-turn agentic conversation.
|
||||
*
|
||||
* Clients resend the full growing message/input history on every turn (no
|
||||
* server-side state dependency), so continuation is detected by checking
|
||||
* whether an earlier request's history is a strict prefix of the current
|
||||
* request's history. This is a new, persisted mechanism — separate from
|
||||
* server-side state dependency). Continuation is detected with a per-turn
|
||||
* hash chain (each turn's id = sha256(parentId, role, sha256(text)), the
|
||||
* same idea as a git commit graph): a new request's turns are walked from
|
||||
* the start against the candidate conversation's existing chain, matching as
|
||||
* far as they agree. Real agentic-CLI traffic (OpenClaw and similar) often
|
||||
* edits or duplicates a turn mid-history to keep provider-side prompt caches
|
||||
* warm — e.g. request 1 has turns `a b c … h i`, request 2 has
|
||||
* `a b c′ … h i′ i j k`. A whole-history hash (the original approach) breaks
|
||||
* on any such edit and never reconnects.
|
||||
*
|
||||
* Every OmniRoute conversation is a single straight line — it never forks.
|
||||
* When a turn diverges from what's already on file (`c` became `c'`), that
|
||||
* diverging history becomes its OWN independent conversation, with its own
|
||||
* id, built fresh from this request's full turn list — not a branch grafted
|
||||
* onto the old chain (2026-08-06 redesign; the branching model's real
|
||||
* traffic accumulated dozens of edits per session, and indenting one more
|
||||
* tree level per edit eventually left no room to show content at all).
|
||||
* `a b c d` and `a b c' d'` end up as two distinct conversations, sharing no
|
||||
* further storage after the point they diverge — simpler to store, query,
|
||||
* and render than a tree, and it matches how the data is actually used: a
|
||||
* "conversation" here is one continuous transcript, not a version-control
|
||||
* graph. This is a new, persisted mechanism — separate from
|
||||
* `sessionManager.ts`'s `generateSessionId()` (in-memory, routing/latency
|
||||
* only) even though it uses the same sha256-fingerprint style.
|
||||
*
|
||||
@@ -16,8 +35,11 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
createAgenticConversation,
|
||||
findAgenticConversationsByFingerprint,
|
||||
getConversationTurnIndex,
|
||||
insertConversationTurnNodes,
|
||||
touchOrCreateExternalConversation,
|
||||
updateAgenticConversation,
|
||||
type ConversationTurnIndex,
|
||||
} from "../../src/lib/db/agenticConversations.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -25,6 +47,16 @@ type JsonRecord = Record<string, unknown>;
|
||||
interface CanonicalTurn {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
text: string;
|
||||
/** 'text' | 'tool_use' | 'tool_result' — carried through to
|
||||
* conversation_turn_nodes so the tree view (and any other consumer) can
|
||||
* build the exact NormalizedBlock (src/mitm/inspector/types.ts) the
|
||||
* request-detail panel already builds from buildRequestTurns/
|
||||
* buildResponseTurns, rendering tool calls/results through the same
|
||||
* ChatBubble/MessageContent/ToolCallBlock/ToolResultBlock components
|
||||
* everywhere instead of a parallel tree-only implementation. */
|
||||
blockKind: "text" | "tool_use" | "tool_result";
|
||||
/** Set only when blockKind === "tool_use". */
|
||||
toolName: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveConversationIdInput {
|
||||
@@ -33,6 +65,15 @@ export interface ResolveConversationIdInput {
|
||||
apiKeyId: string | null;
|
||||
/** Raw `x-omniroute-session-id` header value, if the client supplied one. */
|
||||
clientSessionIdHeader: string | null;
|
||||
/**
|
||||
* call_logs.correlation_id for this request (109_call_logs_correlation_id)
|
||||
* — generated earlier in the request lifecycle, well before this request's
|
||||
* own call_logs row/id exists, so it's the only stable identifier
|
||||
* available here to tag new turn nodes with. The tree API route
|
||||
* (src/app/api/conversations/[id]/tree/route.ts) joins through it to
|
||||
* resolve a navigable call_logs.id.
|
||||
*/
|
||||
correlationId: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveConversationIdResult {
|
||||
@@ -50,9 +91,47 @@ function normalizeRole(raw: unknown): CanonicalTurn["role"] {
|
||||
return "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract human-readable text from an OpenAI/Anthropic/Responses-API
|
||||
* `content` value. Chat Completions sends a plain string; Responses API and
|
||||
* Anthropic send an array of typed blocks (`{type:"text"|"input_text"|
|
||||
* "output_text", text}`, `tool_use`, `tool_result`, ...) — collapsing that
|
||||
* array to its text (rather than `JSON.stringify`-ing the whole thing) is
|
||||
* what feeds both the turn-hash-chain (so the same underlying text chains
|
||||
* identically regardless of which block-array shape a client used to send
|
||||
* it) and `text_preview`, which the /dashboard/conversations tree view
|
||||
* renders directly as markdown — a raw JSON blob there was a real bug, not a
|
||||
* cosmetic one.
|
||||
*/
|
||||
function stringifyContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (content == null) return "";
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") {
|
||||
parts.push(item);
|
||||
continue;
|
||||
}
|
||||
const block = item && typeof item === "object" ? (item as JsonRecord) : null;
|
||||
if (!block) continue;
|
||||
const type = block.type;
|
||||
if (
|
||||
(type === "text" || type === "input_text" || type === "output_text") &&
|
||||
typeof block.text === "string"
|
||||
) {
|
||||
parts.push(block.text);
|
||||
} else if (type === "tool_use" || type === "function_call") {
|
||||
const name = typeof block.name === "string" ? block.name : "";
|
||||
parts.push(`[tool_use ${name}]`);
|
||||
} else if (type === "tool_result" || type === "function_call_output") {
|
||||
parts.push(stringifyContent(block.content ?? block.output ?? ""));
|
||||
} else if (typeof block.text === "string") {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content);
|
||||
} catch {
|
||||
@@ -96,7 +175,25 @@ export function extractCanonicalTurns(body: JsonRecord | null | undefined): Cano
|
||||
: null;
|
||||
if (!role) continue;
|
||||
const text = stringifyContent(rec.content ?? rec.text ?? rec.arguments ?? rec.output);
|
||||
if (text) turns.push({ role, text });
|
||||
if (!text) continue;
|
||||
|
||||
// Chat Completions tool-result messages (role: "tool"/"function") and
|
||||
// Responses API function_call/function_call_output items are the only
|
||||
// two shapes this canonicalizer sees for tool activity — everything
|
||||
// else (including plain assistant/user/system text) is "text".
|
||||
let blockKind: CanonicalTurn["blockKind"] = "text";
|
||||
let toolName: string | null = null;
|
||||
if (rec.type === "function_call") {
|
||||
blockKind = "tool_use";
|
||||
toolName = typeof rec.name === "string" ? rec.name : null;
|
||||
} else if (rec.type === "function_call_output") {
|
||||
blockKind = "tool_result";
|
||||
} else if (rec.role === "tool" || rec.role === "function") {
|
||||
blockKind = "tool_result";
|
||||
toolName = typeof rec.name === "string" ? rec.name : null;
|
||||
}
|
||||
|
||||
turns.push({ role, text, blockKind, toolName });
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
@@ -107,24 +204,6 @@ function hashHex(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function hashShort(text: string): string {
|
||||
return hashHex(text).slice(0, 16);
|
||||
}
|
||||
|
||||
// Deliberately ignores the system message. Real coding-agent CLIs (Claude
|
||||
// Code, opencode, etc.) commonly regenerate the system prompt on EVERY
|
||||
// request with live context (timestamp, cwd, git status...) — anchoring
|
||||
// identity to it would mint a brand new conversation on every single turn
|
||||
// for exactly that kind of real traffic, even though the actual
|
||||
// user/assistant history is a genuine, unbroken continuation. Discovered
|
||||
// live on a real deployment: 28 consecutive requests from one growing
|
||||
// session, each recorded as its own turn_count=1 conversation (issue #9315
|
||||
// follow-up).
|
||||
function extractFirstNonSystemText(turns: CanonicalTurn[]): string | null {
|
||||
const first = turns.find((t) => t.role !== "system");
|
||||
return first ? first.text : null;
|
||||
}
|
||||
|
||||
function extractToolNames(body: JsonRecord | null | undefined): string[] {
|
||||
if (!body || !Array.isArray(body.tools)) return [];
|
||||
const names: string[] = [];
|
||||
@@ -138,53 +217,177 @@ function extractToolNames(body: JsonRecord | null | undefined): string[] {
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
// Deliberately excludes any message text — both the system prompt (real
|
||||
// coding-agent CLIs like Claude Code/opencode regenerate it every request
|
||||
// with live context: timestamp, cwd, git status...) AND, discovered live on
|
||||
// a real OmniRoute deployment running OpenClaw, the first non-system turn
|
||||
// too: OpenClaw's sliding context window drops/summarizes the EARLIEST
|
||||
// turns as a session grows, so `firstNonSystemText` never stays stable
|
||||
// across requests either — anchoring identity to either one mints a brand
|
||||
// new conversation (or, worse, finds zero fingerprint candidates at all, so
|
||||
// the turn-chain match in resolveConversationId never even runs) on every
|
||||
// single turn for exactly this kind of real traffic, even though the actual
|
||||
// history is a genuine, unbroken continuation. The bucket only needs to be
|
||||
// small enough to bound candidate lookup — apiKeyId + model + toolNames is
|
||||
// stable across a whole session and still narrow in practice; actual
|
||||
// identity is decided by the turn-chain walk (real content overlap), not by
|
||||
// this bucket, so widening it here cannot cause a false merge on its own.
|
||||
export function computeFingerprintHash(input: {
|
||||
apiKeyId: string | null;
|
||||
model: string | null;
|
||||
turns: CanonicalTurn[];
|
||||
toolNames: string[];
|
||||
}): string {
|
||||
const firstText = extractFirstNonSystemText(input.turns);
|
||||
const parts = [
|
||||
input.apiKeyId ?? "",
|
||||
input.model ?? "",
|
||||
firstText ? hashShort(firstText) : "",
|
||||
input.toolNames.join(","),
|
||||
];
|
||||
const parts = [input.apiKeyId ?? "", input.model ?? "", input.toolNames.join(",")];
|
||||
// NOTE: no connectionId — conversation identity must not depend on which
|
||||
// upstream connection this particular turn happened to be routed to.
|
||||
return hashHex(parts.join("|"));
|
||||
}
|
||||
|
||||
// ── Bounded turn hash (continuation check, O(1) on very long histories) ──
|
||||
// ── Turn hash chain (continuation + branch detection) ────────────────────
|
||||
//
|
||||
// A history of 100k+ tokens (observed in real traffic) makes hashing the
|
||||
// FULL turn array on every request expensive. Instead hash a bounded
|
||||
// projection: total turn count, the role sequence (cheap - one char per
|
||||
// turn), and the text of the first 2 and last 3 turns. This is a documented
|
||||
// heuristic trade-off: two histories differing ONLY in an untouched middle
|
||||
// section, with the same length, would collide. Accepted for the O(1) cost
|
||||
// bound; see conversationTracker.test.ts for the explicit test pinning this
|
||||
// known limitation.
|
||||
const HEAD_TURNS = 2;
|
||||
const TAIL_TURNS = 3;
|
||||
// Each turn gets a stable id chained to its predecessor, the same idea as a
|
||||
// git commit graph: id = sha256(parentId, role, sha256(text)). A brand-new
|
||||
// tree's first turn chains off the conversation root id itself (not off
|
||||
// `null`) so two different, unrelated conversation trees whose first turn
|
||||
// happens to be byte-identical (e.g. two sessions that both open with "hi")
|
||||
// never compute the same node id — `conversation_turn_nodes.id` is a global
|
||||
// primary key, not scoped per conversation_id.
|
||||
// Bounded well past a UI "preview" length: the /dashboard/conversations tree
|
||||
// view renders this as full markdown per turn (matching the request detail
|
||||
// panel's rendering), not a truncated one-liner — 8000 chars comfortably
|
||||
// covers a real single turn (a system prompt or a long assistant reply)
|
||||
// while still bounding pathological outliers.
|
||||
const TEXT_PREVIEW_LENGTH = 8000;
|
||||
|
||||
export function hashTurnsBounded(turns: CanonicalTurn[]): string {
|
||||
// Same reasoning as extractFirstNonSystemText: a regenerated-every-turn
|
||||
// system prompt must not be part of the continuation signal, or it alone
|
||||
// breaks the head-hash for real CLI traffic even once the fingerprint
|
||||
// bucket lookup (above) correctly finds the right candidate.
|
||||
const relevant = turns.filter((t) => t.role !== "system");
|
||||
const head = relevant.slice(0, HEAD_TURNS);
|
||||
const tail = relevant.length > HEAD_TURNS ? relevant.slice(-TAIL_TURNS) : [];
|
||||
const roleSequence = relevant.map((t) => t.role[0]).join("");
|
||||
const parts = [
|
||||
String(relevant.length),
|
||||
roleSequence,
|
||||
...head.map((t) => t.text),
|
||||
...tail.map((t) => t.text),
|
||||
];
|
||||
return hashHex(parts.join(""));
|
||||
function hashTurnContent(turn: CanonicalTurn): string {
|
||||
return hashHex(`${turn.role} | ||||